Improve TUI contrast, keyboard hints, and picker layouts (#46680)

## Why

Theme colors and dimmed text can lose contrast against terminal and shaded backgrounds. Pickers also need to keep controls and the selected result visible when space is limited.

## What changed

- Add shared blue accents and contrast-aware foregrounds that account for painted backgrounds and terminal color depth, with terminal-default fallbacks for limited palettes.
- Apply readable colors to footer text, status lines, prompt attachment labels, and diff content. Remove dimming from syntax-highlighted diff text so it does not undo contrast correction.
- Use compact shortcut labels such as `ctrl+c`, emphasize complete key combinations, and separate composer footer actions with ` · `.
- Add an opt-in picker presentation with full-width selection, filled tabs, separate overflow indicators, and navigation based on visible rows. Enable it for `/keymap`, reserving result space to keep controls stable across filtering and tab changes.

## Testing

Add regression tests and snapshots for contrast after palette conversion, shaded diff backgrounds, shortcut typography, picker resizing and pagination, Unicode tab truncation, and stable `/keymap` geometry.

GitOrigin-RevId: f3c71879d3bcc4e3b29d70e63cf377dea5914916
This commit is contained in:
Eric Traut
2026-09-19 17:46:43 +00:00
committed by copyberry
parent a5290028a2
commit 132c2be239
199 changed files with 3424 additions and 1150 deletions
+1 -7
View File
@@ -9,14 +9,12 @@ use crate::analytics::models::AccountAnalyticsValue;
use crate::analytics::sections::Section;
use crate::keymap::ListAction;
use crate::style::accent_style;
use crate::style::footer_hint_key_style;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
@@ -171,11 +169,7 @@ impl AnalyticsView {
spans.push(" · ".into());
}
let (keys, description) = control.rsplit_once(' ').unwrap_or((control, ""));
spans.push(
keys.to_owned()
.fg(Color::Reset)
.patch_style(footer_hint_key_style().bold().not_dim()),
);
spans.extend(crate::key_hint::key_label_spans(keys));
if !description.is_empty() {
spans.push(format!(" {description}").into());
}
@@ -23,8 +23,8 @@ Palette:
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 22:1 24:0
1: 0:0 1:2 3:0 18:3 30:0
@@ -17,8 +17,8 @@ Palette:
2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
5: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 30:1 31:0
1: 0:0 1:2 2:0 27:3 34:0
@@ -43,8 +43,8 @@ Palette:
2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
5: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 30:1 31:0
1: 0:0 1:2 2:0 27:3 34:0
@@ -68,9 +68,9 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
6: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 6:1 7:0
1: 0:0 5:2 7:0
@@ -93,7 +93,7 @@ Palette:
0: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
Rows (column:palette):
0: 0:0
@@ -16,7 +16,7 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
Rows (column:palette):
0: 0:0
1: 0:1
@@ -40,8 +40,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1
@@ -65,8 +65,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 4:1
@@ -90,8 +90,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 5:1
@@ -17,8 +17,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 1:1 5:0
1: 0:0 1:2 6:0
@@ -44,8 +44,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 15:1 19:0
1: 0:0 1:2 5:0 12:3 24:0
@@ -71,8 +71,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 18:1 22:0
1: 0:0 1:2 5:0 14:3 26:0
@@ -98,8 +98,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 22:1 26:0
1: 0:0 1:2 5:0 19:3 31:0
@@ -125,8 +125,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 4:1 24:0
@@ -14,8 +14,8 @@ Palette:
1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 4:1
1: 0:1 1:2 5:1 8:3 9:1
@@ -37,8 +37,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 8:1 9:0
@@ -17,8 +17,8 @@ Palette:
2: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 3:0 21:2 26:0
@@ -20,8 +20,8 @@ Palette:
1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 91:1 94:0
1: 0:0 1:1 4:0 77:1 80:0
@@ -24,8 +24,8 @@ Palette:
4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
6: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
7: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
8: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
7: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold()
8: Style::new().fg(Color::Rgb(99, 168, 248)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 5:0 9:2 13:0
@@ -24,8 +24,8 @@ Palette:
4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
6: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
7: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold()
8: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
7: Style::new().fg(Color::Rgb(28, 100, 200)).bg(Color::Reset).underline_color(Color::Reset).bold()
8: Style::new().fg(Color::Rgb(28, 100, 200)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 5:0 9:2 13:0
@@ -17,8 +17,8 @@ Palette:
1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 11:1 17:0
1: 0:0 1:2 4:0 8:3 20:0
@@ -50,8 +50,8 @@ Palette:
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
5: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
6: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 12:1 18:0
1: 0:0 2:2 5:0 9:3 21:0
@@ -15,8 +15,8 @@ Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
2: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 4:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: White|Reset|Reset|BOLD
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0 2:5 4:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 22:10 23:2 34:10 37:2 45:10 46:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: White|Reset|Reset|BOLD
6: Green|Reset|Reset|NONE
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 24:10 27:2 35:10 36:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: White|Reset|Reset|BOLD
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 24:10 27:2 35:10 36:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,8 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -94,5 +94,5 @@ Rows (column:palette):
37: 0:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 14:1 17:2 25:1 26:2 118:0
40: 0:0 2:5 9:2 20:5 21:2 34:5 37:2 44:5 49:2 118:0
41: 0:0 2:5 3:2 14:5 17:2 25:5 26:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: White|Reset|Reset|BOLD
6: Green|Reset|Reset|NONE
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 24:10 27:2 35:10 36:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,8 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -94,5 +94,5 @@ Rows (column:palette):
37: 0:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 118:0
41: 0:0 2:1 3:2 11:1 12:2 23:1 26:2 34:1 35:2 118:0
40: 0:0 2:5 9:2 20:5 21:2 118:0
41: 0:0 2:5 3:2 11:5 12:2 23:5 26:2 34:5 35:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: White|Reset|Reset|BOLD
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 22:10 23:2 34:10 37:2 45:10 46:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: White|Reset|Reset|BOLD
6: Green|Reset|Reset|NONE
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 24:10 27:2 35:10 36:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,8 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -94,5 +94,5 @@ Rows (column:palette):
37: 0:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 29:1 30:2 43:1 46:2 53:1 58:2 118:0
41: 0:0 2:1 3:2 14:1 17:2 25:1 26:2 118:0
40: 0:0 2:5 9:2 20:5 21:2 29:5 30:2 43:5 46:2 53:5 58:2 118:0
41: 0:0 2:5 3:2 14:5 17:2 25:5 26:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: White|Reset|Reset|BOLD
6: Green|Reset|Reset|NONE
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 22:1 23:2 34:1 37:2 45:1 46:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 22:10 23:2 34:10 37:2 45:10 46:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,14 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: White|Reset|Reset|BOLD
6: Green|Reset|Reset|NONE
7: Magenta|Reset|Reset|NONE
8: Cyan|Reset|Reset|NONE
9: Cyan|Reset|Reset|BOLD | UNDERLINED
9: Rgb(99, 168, 248)|Reset|Reset|BOLD | UNDERLINED
10: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -99,5 +99,5 @@ Rows (column:palette):
37: 0:0 2:6 4:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 34:1 37:2 44:1 49:2 118:0
41: 0:0 2:1 3:2 12:1 13:2 24:1 27:2 35:1 36:2 118:0
40: 0:0 2:10 9:2 20:10 21:2 34:10 37:2 44:10 49:2 118:0
41: 0:0 2:10 3:2 12:10 13:2 24:10 27:2 35:10 36:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,8 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(165, 165, 165)|Reset|Reset|NONE
3: Cyan|Reset|Reset|BOLD
3: Rgb(99, 168, 248)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Rgb(230, 230, 230)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -94,5 +94,5 @@ Rows (column:palette):
37: 0:0
38: 0:0
39: 0:0
40: 0:0 2:1 9:2 20:1 21:2 118:0
41: 0:0 2:1 3:2 11:1 12:2 23:1 26:2 34:1 35:2 118:0
40: 0:0 2:5 9:2 20:5 21:2 118:0
41: 0:0 2:5 3:2 11:5 12:2 23:5 26:2 34:5 35:2 118:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,9 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Black|Reset|Reset|BOLD
5: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,9 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Black|Reset|Reset|BOLD
5: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,9 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Black|Reset|Reset|BOLD
5: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,13 +50,13 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Green|Reset|Reset|NONE
6: Magenta|Reset|Reset|NONE
7: Cyan|Reset|Reset|NONE
8: Rgb(0, 95, 135)|Reset|Reset|BOLD | UNDERLINED
9: Black|Reset|Reset|BOLD
8: Rgb(28, 100, 200)|Reset|Reset|BOLD | UNDERLINED
9: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0 2:3 6:0 11:2 34:0
@@ -1,6 +1,5 @@
---
source: tui/src/analytics/styles_tests.rs
assertion_line: 145
expression: snapshot
---
120x42
@@ -51,9 +50,9 @@ Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
2: Rgb(76, 76, 76)|Reset|Reset|NONE
3: Rgb(0, 95, 135)|Reset|Reset|BOLD
3: Rgb(28, 100, 200)|Reset|Reset|BOLD
4: Reset|Reset|Reset|DIM
5: Black|Reset|Reset|BOLD
5: Rgb(38, 38, 38)|Reset|Reset|BOLD
Rows (column:palette):
0: 0:0 2:1 11:2 28:0
1: 0:0
+7 -7
View File
@@ -164,8 +164,8 @@ async fn capped_resize_reflow_prepends_transcript_notice_without_changing_transc
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n"),
@r"
Earlier messages are available — press ctrl + t to view the full transcript
@"
Earlier messages are available — press ctrl+t to view the full transcript
cell 8
cell 9
@@ -193,10 +193,10 @@ async fn capped_resize_reflow_counts_wrapped_notice_rows() {
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n"),
@r"
@"
Earlier messages are
available — press ctrl + t
to view the full transcript
available — press ctrl+t to
view the full transcript
cell 9
cell 10
@@ -243,8 +243,8 @@ async fn paginated_resize_reflow_prepends_transcript_notice_for_unloaded_history
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n"),
@r"
Earlier messages are available — press ctrl + t to view the full transcript
@"
Earlier messages are available — press ctrl+t to view the full transcript
cell 0
cell 1
+1 -1
View File
@@ -281,7 +281,7 @@ impl App {
) {
label_parts.push(format!("{} to switch", binding.display_label()));
}
label_parts.push("ctrl + c to close".to_string());
label_parts.push("ctrl+c to close".to_string());
self.chat_widget
.set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · "))));
}
+6 -7
View File
@@ -5090,11 +5090,11 @@ async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
);
assert_snapshot!(
format!("{input_footer}\n{approval_footer}\n{cleared_footer}"),
@r"
Side from main thread · main needs input · ctrl + / to switch · ctrl + c to close
Side from main thread · main needs approval · ctrl + / to switch · ctrl + c to close
Side from main thread · ctrl + / to switch · ctrl + c to close
"
@"
Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close
Side from main thread · main needs approval · ctrl+/ to switch · ctrl+c to close
Side from main thread · ctrl+/ to switch · ctrl+c to close
"
);
Ok(())
@@ -6506,8 +6506,7 @@ async fn capped_resize_reflow_renders_recent_suffix_only() {
.map(rendered_line_text)
.collect::<Vec<_>>(),
vec![
"Earlier messages are available — press ctrl + t to view the full transcript"
.to_string(),
"Earlier messages are available — press ctrl+t to view the full transcript".to_string(),
String::new(),
"cell 18".to_string(),
String::new(),
+5 -5
View File
@@ -102,11 +102,11 @@ async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<(
insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.replace(&test_path_display("/tmp/project"), "/tmp/project"),
@r"
› Ask Codex to do anything
@"
› Ask Codex to do anything
ctrl + x … waiting for next key esc cancel
"
ctrl+x … waiting for next key · esc cancel
"
);
press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?;
@@ -597,7 +597,7 @@ async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<
let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(),
@" ctrl + x … waiting for next key esc cancel"
@" ctrl+x … waiting for next key esc cancel"
);
assert_eq!(
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()),
@@ -4331,15 +4331,15 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.replace(&root_thread_id.to_string(), "[root]")
.replace(&child_thread_id.to_string(), "[child]"),
@r###"
Subagents
Select an agent to watch. ⌥ + ← previous, ⌥ + → next.
@"
Subagents
Select an agent to watch. ⌥+← previous, ⌥+→ next.
› 1. • Main [default] (current) [root]
2. • /root/worker [child]
› 1. • Main [default] (current) [root]
2. • /root/worker [child]
Press enter to confirm or esc to go back
"###
Press enter to confirm or esc to go back
"
);
assert_eq!(take_backfill_counts(&requests), (0, 0));
tokio::time::timeout(Duration::from_secs(5), started_rx).await??;
@@ -7,4 +7,4 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
› Ask Codex to do anything
ctrl+c quit
ctrl+c quit
@@ -4,7 +4,7 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
---
• Queued follow-up inputs
↳ old queued input
⌥ + ↑ edit last queued message
⌥+↑ edit last queued message
› kept draft
@@ -4,7 +4,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, 80))
---
• Queued follow-up inputs
↳ keep queued
⌥ + ↑ edit last queued message
⌥+↑ edit last queued message
› keep draft
@@ -7,8 +7,8 @@ expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.
• Queued follow-up inputs
↳ queued before disconnect
⌥ + ↑ edit last queued message
⌥+↑ edit last queued message
› keep this draft!
ctrl+c quit
ctrl+c quit
@@ -2,7 +2,7 @@
source: tui/src/app/tests/pagination_completion_tests.rs
expression: "snapshots.join(\"\\n\\n--- next overlapping page ---\\n\\n\")"
---
Earlier messages are available — press ctrl + t to view the full transcript
Earlier messages are available — press ctrl+t to view the full transcript
• Middle answer
Worked for 2m 5s · Sep 6, 2000 at 2:33 PM
@@ -3,5 +3,5 @@ source: tui/src/app/tests/startup_warnings_tests.rs
expression: warnings.display_lines(100)
---
[
Line::from(Span::from("⚠ 3 MCP startup issues (2 need sign-in) · ctrl + t for details").yellow().dim()),
Line::from(Span::from("⚠ 3 MCP startup issues (2 need sign-in) · ctrl+t for details").yellow().dim()),
]
@@ -3,5 +3,5 @@ source: tui/src/app/tests/startup_warnings_tests.rs
expression: "app.transcript_cells[1].display_lines(100)"
---
[
Line::from(Span::from("⚠ 4 startup issues (3 MCP; 2 need sign-in) · ctrl + t for details").yellow().dim()),
Line::from(Span::from("⚠ 4 startup issues (3 MCP; 2 need sign-in) · ctrl+t for details").yellow().dim()),
]
@@ -142,7 +142,7 @@ async fn startup_warnings_wait_for_splash_and_coalesce_with_full_details() -> Re
let transcript = app.transcript_cells[1].transcript_lines(/*width*/ 80);
insta::assert_snapshot!(format!("display:\n{}\n\ntranscript:\n{}", lines_to_single_string(&display), lines_to_single_string(&transcript)), @"
display:
⚠ 2 startup issues · ctrl + t for details
⚠ 2 startup issues · ctrl+t for details
transcript:
⚠ Skill manifest is invalid.
@@ -188,25 +188,19 @@ impl AsyncQuestions {
if self.keymap.chat.interrupt_turn.contains(&binding)
|| self.keymap.chat.edit_queued_message.contains(&binding)))
{
tips.push(
Span::styled(
format!("{} submit", key.display_label()),
crate::style::accent_style(),
)
.bold(),
);
tips.push(crate::footer_hint::shortcut(&key.display_label(), "submit"));
}
if let Some(key) = chat_hint("skip_question") {
tips.push(format!("{} skip", key.display_label()).dim());
tips.push(crate::footer_hint::shortcut(&key.display_label(), "skip"));
}
tips.extend(option_tip);
tips.extend(option_tip.map(Line::from));
if let Some(key) = chat_hint("prompt_stack_back") {
let label = if self.state.current_idx > 0 {
"prev question"
} else {
"main prompt"
};
tips.push(format!("{} {label}", key.display_label()).dim());
tips.push(crate::footer_hint::shortcut(&key.display_label(), label));
}
let next = if self.state.current_idx + 1 < self.state.pending.len() {
Some("next question")
@@ -218,7 +212,7 @@ impl AsyncQuestions {
if let Some(label) = next
&& let Some(key) = self.next_hint
{
tips.push(format!("{} {label}", key.display_label()).dim());
tips.push(crate::footer_hint::shortcut(&key.display_label(), label));
}
let mut lines = Vec::new();
let mut line = Line::default();
@@ -230,7 +224,7 @@ impl AsyncQuestions {
line.spans.push(TIP_SEPARATOR.into());
}
}
line.spans.push(tip);
line.spans.extend(tip.spans);
}
lines.push(line);
lines
@@ -16,5 +16,4 @@ expression: buffer_text(&buffer)
long draft
enter submit ctrl + ] skip
⌥ + ↓ main prompt
enter submit ctrl+] skip ⌥+↓ main prompt
@@ -11,5 +11,5 @@ expression: buffer_text(&buffer)
› 13. earlier answer
reverse-i-search: e enter accept · esc
enter submit ctrl + ] skip option 13/13
⌥ + ↓ prev question
enter submit ctrl+] skip option 13/13
⌥+↓ prev question
@@ -9,5 +9,4 @@ expression: buffer_text(&buffer)
› 1. Other
2. Other (write an answer)
enter submit ctrl + ] skip
⌥ + ↓ prev question
enter submit ctrl+] skip ⌥+↓ prev question
@@ -11,5 +11,5 @@ expression: buffer_text(&buffer)
2. Option 2 is long enough to wrap across
rows
f9 submit ctrl + ] skip option 1/13
⌥ + ↓ prev question
f9 submit ctrl+] skip option 1/13
⌥+↓ prev question
@@ -10,5 +10,5 @@ expression: buffer_text(&buffer)
multiple rows
2. Other
enter submit ctrl + ] skip
⌥ + ↓ main prompt
enter submit ctrl+] skip
⌥+↓ main prompt
@@ -12,5 +12,5 @@ expression: buffer_text(&buffer)
wraps across several option
rows
enter submit ctrl + ] skip
⌥ + ↓ main prompt
enter submit ctrl+] skip
⌥+↓ main prompt
@@ -9,5 +9,5 @@ expression: buffer_text(&clipped)
long enough to wrap across
multiple rows
enter submit ctrl + ] skip
option 2/3 ⌥ + ↓ main prompt
enter submit ctrl+] skip
option 2/3 ⌥+↓ main prompt
@@ -371,7 +371,7 @@ fn existing_cross_context_keymaps_load_without_misleading_submit_hints() {
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.set_keymap(&keymap);
insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl + ] skip ⌥ + ↓ prev question"); }
insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl+] skip ⌥+↓ prev question"); }
editor.handle_key_event(KeyEvent::from(KeyCode::F(12)));
assert!(editor.submission.is_none());
}
@@ -407,7 +407,7 @@ impl ChatComposer {
}
fn history_search_action_key_span(key: KeyCode) -> Span<'static> {
Span::from(key_hint::plain(key)).cyan().bold().not_dim()
Span::from(key_hint::plain(key))
}
/// Returns byte ranges that should be highlighted in the current composer preview.
@@ -900,63 +900,79 @@ mod tests {
#[test]
fn history_search_footer_action_hints_are_emphasized() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (240, 240, 240),
bg: (24, 24, 24),
},
|| {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer
.history
.record_local_submission(HistoryEntry::new("cargo test".to_string()));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
let line = composer
.history_search_footer_line()
.expect("expected history search footer line");
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec![
"reverse-i-search: ",
"c",
" ",
"enter",
" accept",
" · ",
"esc",
" cancel"
]
);
let query_style = line.spans[1].style;
assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan));
let enter_style = line.spans[3].style;
assert_eq!(
enter_style.fg,
Some(crate::terminal_palette::rgb_color((240, 240, 240)))
);
assert!(enter_style.add_modifier.contains(Modifier::BOLD));
assert!(enter_style.sub_modifier.contains(Modifier::DIM));
let accept_style = line.spans[4].style;
assert!(accept_style.add_modifier.contains(Modifier::DIM));
let separator_style = line.spans[5].style;
assert!(separator_style.add_modifier.contains(Modifier::DIM));
let esc_style = line.spans[6].style;
assert_eq!(
esc_style.fg,
Some(crate::terminal_palette::rgb_color((240, 240, 240)))
);
assert!(esc_style.add_modifier.contains(Modifier::BOLD));
assert!(esc_style.sub_modifier.contains(Modifier::DIM));
let cancel_style = line.spans[7].style;
assert!(cancel_style.add_modifier.contains(Modifier::DIM));
},
);
composer
.history
.record_local_submission(HistoryEntry::new("cargo test".to_string()));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
let line = composer
.history_search_footer_line()
.expect("expected history search footer line");
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec![
"reverse-i-search: ",
"c",
" ",
"enter",
" accept",
" · ",
"esc",
" cancel"
]
);
let query_style = line.spans[1].style;
assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan));
let enter_style = line.spans[3].style;
assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan));
assert!(enter_style.add_modifier.contains(Modifier::BOLD));
assert!(enter_style.sub_modifier.contains(Modifier::DIM));
let accept_style = line.spans[4].style;
assert!(accept_style.add_modifier.contains(Modifier::DIM));
let separator_style = line.spans[5].style;
assert!(separator_style.add_modifier.contains(Modifier::DIM));
let esc_style = line.spans[6].style;
assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan));
assert!(esc_style.add_modifier.contains(Modifier::BOLD));
assert!(esc_style.sub_modifier.contains(Modifier::DIM));
let cancel_style = line.spans[7].style;
assert!(cancel_style.add_modifier.contains(Modifier::DIM));
}
#[test]
@@ -12,13 +12,13 @@ shortcut help:
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl + j for newline tab to submit message
@ for file paths ctrl + v to paste images
ctrl + g to edit in external editor esc esc to edit previous message
ctrl + r search history ctrl + c to exit
⌥ + , reasoning down ⌥ + . reasoning up
ctrl + t to view transcript
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
after closing:
@@ -37,13 +37,13 @@ shortcut help:
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl + j for newline tab to submit message
@ for file paths ctrl + v to paste images
ctrl + g to edit in external editor esc esc to edit previous message
ctrl + r search history ctrl + c to exit
⌥ + , reasoning down ⌥ + . reasoning up
ctrl + t to view transcript
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
after closing:
@@ -6,13 +6,13 @@ shortcut help:
› Ask Codex to do anything
/ for commands ! for shell commands
ctrl + j for newline tab to submit message
@ for file paths ctrl + v to paste images
ctrl + g to edit in external editor esc esc to edit previous message
ctrl + r search history ctrl + c to exit
⌥ + , reasoning down ⌥ + . reasoning up
ctrl + t to view transcript
/ for commands ! for shell commands
ctrl+j for newline tab to submit message
@ for file paths ctrl+v to paste images
ctrl+g to edit in external editor esc esc to edit previous message
ctrl+r search history ctrl+c to exit
⌥+, reasoning down ⌥+. reasoning up
ctrl+t to view transcript
customize shortcuts with /keymap
@@ -209,6 +209,7 @@ impl CommandPopup {
let name = format!("/{}", item.command());
let description = item.description().to_string();
GenericDisplayRow {
selection_style: None,
name,
name_prefix_spans: Vec::new(),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
@@ -118,6 +118,7 @@ impl WidgetRef for &FileSearchPopup {
self.matches
.iter()
.map(|m| GenericDisplayRow {
selection_style: None,
name: m.path.to_string_lossy().to_string(),
name_prefix_spans: Vec::new(),
match_indices: m
+106 -52
View File
@@ -46,10 +46,12 @@ use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::render::line_utils::prefix_lines;
use crate::status::format_tokens_compact;
use crate::style::secondary_text_style;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -149,11 +151,14 @@ impl CollaborationModeIndicator {
}
}
fn styled_span(self, show_cycle_hint: bool) -> Span<'static> {
let label = self.label(show_cycle_hint);
match self {
CollaborationModeIndicator::Plan => Span::from(label).magenta(),
fn styled_line(self, show_cycle_hint: bool) -> Line<'static> {
let mut line = Line::from(self.label(/*show_cycle_hint*/ false).magenta());
if show_cycle_hint {
line.push_span(" (".set_style(secondary_text_style()));
line.extend(key_hint::shift(KeyCode::Tab).spans());
line.push_span(" to cycle)".set_style(secondary_text_style()));
}
line
}
}
@@ -316,36 +321,40 @@ fn left_side_line(
SummaryHintKind::None => {}
SummaryHintKind::Shortcuts => {
if let Some(key) = key_hints.agents {
line.push_span(key);
line.push_span(" for agents".dim());
line.extend(key.spans());
line.push_span(" for agents".set_style(secondary_text_style()));
if key_hints.toggle_shortcuts.is_some() {
line.push_span(" · ".dim());
line.push_span(" · ".set_style(secondary_text_style()));
}
}
if let Some(key) = key_hints.toggle_shortcuts {
line.push_span(key);
line.push_span(" for shortcuts".dim());
line.extend(key.spans());
line.push_span(" for shortcuts".set_style(secondary_text_style()));
}
}
SummaryHintKind::QueueMessage => {
if let Some(key) = key_hints.queue {
line.push_span(key);
line.push_span(" to queue message".dim());
line.extend(key.spans());
line.push_span(" to queue message".set_style(secondary_text_style()));
}
}
SummaryHintKind::QueueShort => {
if let Some(key) = key_hints.queue {
line.push_span(key);
line.push_span(" to queue".dim());
line.extend(key.spans());
line.push_span(" to queue".set_style(secondary_text_style()));
}
}
};
if let Some(collaboration_mode_indicator) = collaboration_mode_indicator {
if !matches!(state.hint, SummaryHintKind::None) {
line.push_span(" · ".dim());
line.push_span(" · ".set_style(secondary_text_style()));
}
line.push_span(collaboration_mode_indicator.styled_span(state.show_cycle_hint));
line.extend(
collaboration_mode_indicator
.styled_line(state.show_cycle_hint)
.spans,
);
}
line
@@ -547,7 +556,7 @@ pub(crate) fn mode_indicator_line(
indicator: Option<CollaborationModeIndicator>,
show_cycle_hint: bool,
) -> Option<Line<'static>> {
indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)]))
indicator.map(|indicator| indicator.styled_line(show_cycle_hint))
}
pub(crate) fn goal_status_indicator_line(
@@ -600,7 +609,7 @@ pub(crate) fn status_line_right_indicator_line(
.flatten()
{
if let Some(line) = line.as_mut() {
line.push_span(" · ".dim());
line.push_span(" · ".set_style(secondary_text_style()));
for span in indicator.spans {
line.push_span(span);
}
@@ -613,11 +622,28 @@ pub(crate) fn status_line_right_indicator_line(
}
pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
if let Some(rest) = label.strip_prefix("Side ") {
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
let mut line = Line::default();
let rest = if let Some(rest) = label.strip_prefix("Side ") {
line.extend(["Side".magenta().bold(), " ".into()]);
rest
} else {
Line::from(vec![Span::from(label.to_string()).magenta()])
label
};
for (index, part) in rest.split(" · ").enumerate() {
if index > 0 {
line.push_span(" · ".set_style(secondary_text_style()));
}
if let Some((keys, action)) = [" to switch", " to close", " for side"]
.into_iter()
.find_map(|action| part.strip_suffix(action).map(|keys| (keys, action)))
{
line.extend(key_hint::key_label_spans(keys));
line.push_span(action.set_style(secondary_text_style()));
} else {
line.push_span(part.to_owned().magenta());
}
}
line
}
fn right_aligned_x(area: Rect, content_width: u16) -> Option<u16> {
@@ -736,7 +762,9 @@ fn footer_from_props_lines(
FooterMode::QuitShortcutReminder => {
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
}
FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()],
FooterMode::HistorySearch => {
vec![Line::from("reverse-i-search: ").set_style(secondary_text_style())]
}
FooterMode::ComposerEmpty => {
let state = LeftSideState {
hint: if show_shortcuts_hint {
@@ -803,10 +831,12 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
if let Some(active_agent_label) = props.active_agent_label.as_ref() {
if let Some(existing) = line.as_mut() {
existing.spans.push(" · ".dim());
existing.spans.push(active_agent_label.clone().dim());
existing.spans.push(" · ".set_style(secondary_text_style()));
existing
.spans
.push(active_agent_label.clone().set_style(secondary_text_style()));
} else {
line = Some(Line::from(active_agent_label.clone()).dim());
line = Some(Line::from(active_agent_label.clone()).set_style(secondary_text_style()));
}
}
@@ -814,7 +844,9 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
&& let Some(key) = props.key_hints.agents
&& let Some(line) = line.as_mut()
{
line.extend(vec![" · ".dim(), key.into(), " for agents".dim()]);
line.push_span(" · ".set_style(secondary_text_style()));
line.extend(key.spans());
line.push_span(" for agents".set_style(secondary_text_style()));
}
line
@@ -873,19 +905,18 @@ pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 {
pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
let mut spans = Vec::with_capacity(items.len() * 4);
for (idx, (key, label)) in items.iter().enumerate() {
spans.push(" ".into());
spans.push(key.clone().bold());
if idx > 0 {
spans.push(" · ".set_style(secondary_text_style()));
}
spans.extend(key_hint::key_label_spans(key));
if idx == 0
&& key == "voice"
&& let Some(label) = label.strip_prefix("● ")
{
spans.push(" ●".red());
spans.push(format!(" {label}").into());
spans.push(format!(" {label}").set_style(secondary_text_style()));
} else {
spans.push(format!(" {label}").into());
}
if idx + 1 != items.len() {
spans.push((if items[0].0 == "voice" { " " } else { " " }).into());
spans.push(format!(" {label}").set_style(secondary_text_style()));
}
}
Line::from(spans)
@@ -903,21 +934,23 @@ struct ShortcutsState {
}
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
Line::from(vec![key.into(), " again to quit".into()]).dim()
let mut line = Line::from(key.spans());
line.push_span(" again to quit".set_style(secondary_text_style()));
line
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
let esc = key_hint::plain(KeyCode::Esc);
if esc_backtrack_hint {
Line::from(vec![esc.into(), " again to edit previous message".into()]).dim()
let mut line = Line::from(esc.spans());
line.push_span(" again to edit previous message".set_style(secondary_text_style()));
line
} else {
Line::from(vec![
esc.into(),
" ".into(),
esc.into(),
" to edit previous message".into(),
])
.dim()
let mut line = Line::from(esc.spans());
line.push_span(" ".set_style(secondary_text_style()));
line.extend(esc.spans());
line.push_span(" to edit previous message".set_style(secondary_text_style()));
line
}
}
@@ -979,14 +1012,20 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut lines = build_columns(ordered);
if let Some(key) = state.key_hints.agents {
lines.push(Line::from(vec![
key.into(),
" for agents (empty prompt)".into(),
key.display_label()
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim(),
" for agents (empty prompt)".set_style(secondary_text_style()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
"customize shortcuts with ".into(),
"/keymap".cyan(),
"customize shortcuts with ".set_style(secondary_text_style()),
"/keymap"
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim(),
]));
lines
}
@@ -1033,7 +1072,7 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
line.push_span(Span::from(" ".repeat(padding)));
}
}
line.dim()
line.set_style(secondary_text_style())
})
.collect()
}
@@ -1041,15 +1080,21 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
pub(crate) fn context_window_line(percent: Option<i64>, used_tokens: Option<i64>) -> Line<'static> {
if let Some(percent) = percent {
let percent = percent.clamp(0, 100);
return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]);
return Line::from(vec![
Span::from(format!("{percent}% context left")).set_style(secondary_text_style()),
]);
}
if let Some(tokens) = used_tokens {
let used_fmt = format_tokens_compact(tokens);
return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]);
return Line::from(vec![
Span::from(format!("{used_fmt} used")).set_style(secondary_text_style()),
]);
}
Line::from(vec![Span::from("100% context left").dim()])
Line::from(vec![
Span::from("100% context left").set_style(secondary_text_style()),
])
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1134,7 +1179,12 @@ impl ShortcutDescriptor {
.binding_for(state)
.map(|binding| ShortcutHint::Single(binding.key)),
}?;
let mut line = Line::from(vec![self.prefix.into(), key.into()]);
let reference_key = key
.display_label()
.fg(crate::style::accent_color_on(/*background*/ None))
.not_bold()
.not_dim();
let mut line = Line::from(vec![self.prefix.into(), reference_key.clone()]);
match self.id {
ShortcutId::QueueMessageTab => {
if state.is_task_running || state.queue_submissions {
@@ -1149,7 +1199,7 @@ impl ShortcutDescriptor {
} else {
line.extend(vec![
" ".into(),
key.into(),
reference_key,
" to edit previous message".into(),
]);
}
@@ -1310,6 +1360,10 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
},
];
#[cfg(test)]
#[path = "footer_typography_tests.rs"]
mod typography_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1324,7 +1378,7 @@ mod tests {
#[test]
fn voice_live_microphone_indicator_is_red() {
let line = footer_hint_items_line(&[("voice".into(), "● listen".into())]);
assert_eq!(line.spans[2].style.fg, Some(ratatui::style::Color::Red));
assert_eq!(line.spans[1].style.fg, Some(ratatui::style::Color::Red));
}
#[test]
@@ -0,0 +1,88 @@
//! Legacy help keeps accent keys while ordinary footer keys clear inherited emphasis.
use super::*;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
#[test]
fn help_reference_and_footer_preserve_distinct_key_styles() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (32, 32, 32),
bg: (255, 255, 255),
},
|| {
let help = shortcut_overlay_lines(ShortcutsState {
use_shift_enter_hint: false,
esc_backtrack_hint: false,
is_task_running: false,
queue_submissions: false,
is_wsl: false,
collaboration_modes_enabled: false,
key_hints: FooterKeyHints::default_bindings(),
});
let help_height = help.len() as u16;
let footer = footer_hint_items_line(&[
("ctrl+o".into(), "then".into()),
("o".into(), "copy".into()),
]);
let mut terminal = Terminal::new(TestBackend::new(
/*width*/ 80,
/*height*/ help_height + 1,
))
.unwrap();
terminal
.draw(|frame| {
let inherited = Style::default().fg(Color::Green).bold().dim();
frame.render_widget(
Paragraph::new(help).style(inherited),
Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 80, help_height),
);
frame.render_widget(
Paragraph::new(footer).style(inherited),
Rect::new(
/*x*/ 0,
help_height,
/*width*/ 80,
/*height*/ 1,
),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let styles = [
(0, 0),
(2, 0),
(0, help_height),
(4, help_height),
(7, help_height),
]
.map(|position| {
let cell = &buffer[position];
(cell.fg, cell.modifier)
});
let key_color = key_hint::ctrl(KeyCode::Char('o')).spans()[0]
.style
.fg
.unwrap();
let secondary_color = secondary_text_style().fg.unwrap();
assert_eq!(
styles,
[
(
crate::style::accent_color_on(/*background*/ None),
Modifier::empty()
),
(secondary_color, Modifier::empty()),
(key_color, Modifier::BOLD),
(key_color, Modifier::BOLD),
(secondary_color, Modifier::empty()),
]
);
},
);
}
@@ -1093,16 +1093,7 @@ mod tests {
"hooks_browser_events_with_review_column",
render_lines(&view, /*width*/ 112)
);
assert_eq!(
view.event_table_lines()[1].spans[3].style.fg,
Some(Color::Cyan)
);
assert!(
view.event_table_lines()[1].spans[3]
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
assert_eq!(view.event_table_lines()[1].spans[3].style, accent_style());
}
#[test]
@@ -1,3 +1,6 @@
//! Render selectable lists while existing callers retain the legacy presentation.
//! Picker appearance opts into stable controls, full-width selection, and separate overflow rows.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -11,6 +14,10 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use super::selection_picker_layout::PickerLayoutSizes;
use super::selection_picker_layout::picker_areas;
use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
@@ -21,12 +28,17 @@ use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::bottom_pane_view::ViewCompletion;
pub(crate) use super::picker_style::PickerSurface;
use super::picker_style::render_scroll_indicators;
use super::picker_style::selection_style;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::ColumnWidthConfig;
@@ -37,6 +49,7 @@ use super::selection_popup_common::render_rows_single_line_with_col_width_mode;
use super::selection_popup_common::render_rows_with_col_width_mode;
pub(crate) use super::selection_row_layout::SelectionDescriptionLayout;
use super::selection_tabs::SelectionTab;
use super::selection_tabs::TabAppearance;
use super::selection_tabs::render_tab_bar;
use super::selection_tabs::tab_bar_height;
use unicode_width::UnicodeWidthStr;
@@ -105,6 +118,15 @@ pub(crate) enum SelectionRowDisplay {
SingleLine,
}
/// Visual treatment is opt-in while existing workflows retain their current layout.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum SelectionAppearance {
#[default]
Legacy,
/// Full-width focus and separate overflow rows on a terminal or panel surface.
Picker,
}
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
@@ -180,6 +202,12 @@ pub(crate) struct SelectionItem {
/// `description_layout` optionally moves descriptions below labels when their
/// column would become too narrow.
pub(crate) struct SelectionViewParams {
pub appearance: SelectionAppearance,
pub picker_surface: PickerSurface,
/// Upper row budget; compact completion menus retain the default of eight.
pub max_visible_rows: usize,
/// Keep the result area at its configured row budget when filtering shrinks it.
pub reserve_result_rows: bool,
pub view_id: Option<&'static str>,
pub title: Option<String>,
pub subtitle: Option<String>,
@@ -234,6 +262,10 @@ pub(crate) struct SelectionViewParams {
impl Default for SelectionViewParams {
fn default() -> Self {
Self {
appearance: SelectionAppearance::default(),
picker_surface: PickerSurface::default(),
max_visible_rows: MAX_POPUP_ROWS,
reserve_result_rows: false,
view_id: None,
title: None,
subtitle: None,
@@ -270,6 +302,10 @@ impl Default for SelectionViewParams {
/// visible rows and source items and for preserving selection while filters
/// change.
pub(crate) struct ListSelectionView {
appearance: SelectionAppearance,
picker_surface: PickerSurface,
max_visible_rows: usize,
reserve_result_rows: bool,
view_id: Option<&'static str>,
footer_note: Option<Line<'static>>,
footer_hint: Option<Line<'static>>,
@@ -384,11 +420,20 @@ impl ListSelectionView {
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
header = Box::new(ColumnRenderable::with([
header,
Box::new(title),
Box::new(subtitle),
]));
header = match params.appearance {
SelectionAppearance::Legacy => Box::new(ColumnRenderable::with([
header,
Box::new(title),
Box::new(subtitle),
])),
SelectionAppearance::Picker => Box::new(ColumnRenderable::with([
header,
Box::new(
Paragraph::new(title.into_iter().chain(subtitle).collect::<Vec<_>>())
.wrap(Wrap { trim: false }),
),
])),
};
}
let active_tab_idx = params.initial_tab_id.as_ref().and_then(|initial_tab_id| {
params
@@ -403,6 +448,10 @@ impl ListSelectionView {
};
let has_initial_selected_idx = params.initial_selected_idx.is_some();
let mut s = Self {
appearance: params.appearance,
picker_surface: params.picker_surface,
max_visible_rows: params.max_visible_rows.max(/*other*/ 1),
reserve_result_rows: params.reserve_result_rows,
view_id: params.view_id,
footer_note: params.footer_note,
footer_hint: params.footer_hint,
@@ -472,13 +521,31 @@ impl ListSelectionView {
self.items.as_mut_slice()
}
fn active_header(&self) -> &dyn Renderable {
fn active_header(&self, width: u16) -> &dyn Renderable {
self.active_tab_idx
.and_then(|idx| self.tabs.get(idx))
.filter(|tab| {
self.appearance == SelectionAppearance::Legacy
|| tab.header.desired_height(width) > 0
})
.map(|tab| tab.header.as_ref())
.unwrap_or(self.header.as_ref())
}
fn header_height(&self, width: u16) -> u16 {
let active_height = self.active_header(width).desired_height(width);
if self.appearance == SelectionAppearance::Picker && self.reserve_result_rows {
self.tabs
.iter()
.map(|tab| tab.header.desired_height(width))
.max()
.unwrap_or(active_height)
.max(active_height)
} else {
active_height
}
}
fn active_footer_hint(&self) -> Option<&Line<'static>> {
if let Some(action) = self
.selected_actual_idx()
@@ -502,8 +569,14 @@ impl ListSelectionView {
.map(|tab| tab.id.as_str())
}
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
fn visible_rows(&self, len: usize) -> usize {
let rendered = self.rendered_item_count.get();
let limit = if self.appearance == SelectionAppearance::Picker && rendered > 0 {
self.max_visible_rows.min(rendered)
} else {
self.max_visible_rows
};
limit.min(len.max(/*other*/ 1))
}
fn selected_actual_idx(&self) -> Option<usize> {
@@ -572,7 +645,7 @@ impl ListSelectionView {
.or_else(|| self.first_enabled_visible_idx())
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
@@ -598,6 +671,8 @@ impl ListSelectionView {
.to_string()
.len();
let mut enabled_row_number = 0;
let row_selection_style =
(self.appearance == SelectionAppearance::Picker).then(selection_style);
self.filtered_indices
.iter()
.enumerate()
@@ -646,6 +721,7 @@ impl ListSelectionView {
.or_else(|| item.description.clone());
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
GenericDisplayRow {
selection_style: row_selection_style,
name: name_with_marker,
name_prefix_spans,
display_shortcut: item.display_shortcut,
@@ -677,6 +753,9 @@ impl ListSelectionView {
};
self.active_tab_idx = Some(next_idx);
self.search_query.clear();
if self.appearance == SelectionAppearance::Picker {
self.rendered_item_count.set(/*val*/ 0);
}
self.state.reset();
self.apply_filter();
if self.state.selected_idx.is_none() {
@@ -729,7 +808,7 @@ impl ListSelectionView {
let before = self.selected_actual_idx();
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.skip_disabled_up();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
@@ -741,7 +820,7 @@ impl ListSelectionView {
let before = self.selected_actual_idx();
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.skip_disabled_down();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
@@ -752,7 +831,7 @@ impl ListSelectionView {
fn page_up(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.state.page_up_clamped(len, visible);
self.skip_disabled_up_clamped();
self.state.ensure_visible(len, visible);
@@ -764,7 +843,7 @@ impl ListSelectionView {
fn page_down(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.state.page_down_clamped(len, visible);
self.skip_disabled_down_clamped();
self.state.ensure_visible(len, visible);
@@ -776,7 +855,7 @@ impl ListSelectionView {
fn jump_top(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.state.jump_top(len, visible);
self.skip_disabled_down_clamped();
self.state.ensure_visible(len, visible);
@@ -788,7 +867,7 @@ impl ListSelectionView {
fn jump_bottom(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
let visible = self.visible_rows(len);
self.state.jump_bottom(len, visible);
self.skip_disabled_up_clamped();
self.state.ensure_visible(len, visible);
@@ -864,8 +943,52 @@ impl ListSelectionView {
self.last_selected_actual_idx.take()
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
fn tab_appearance(&self) -> TabAppearance {
match self.appearance {
SelectionAppearance::Legacy => TabAppearance::Legacy,
SelectionAppearance::Picker => TabAppearance::Filled,
}
}
fn footer_hint_lines(&self, width: u16) -> Vec<Line<'_>> {
let Some(hint) = self.active_footer_hint() else {
return Vec::new();
};
if self.appearance == SelectionAppearance::Legacy {
return vec![hint.clone()];
}
let mut lines = wrap_styled_line(hint, width);
if self.reserve_result_rows {
let height = self
.tab_footer_hints
.iter()
.map(|(_, hint)| wrap_styled_line(hint, width).len())
.chain(
self.footer_hint
.iter()
.map(|hint| wrap_styled_line(hint, width).len()),
)
.max()
.unwrap_or(lines.len());
lines.resize(height.max(lines.len()), Line::default());
}
lines
}
fn result_area_height(&self, measured_height: u16) -> u16 {
if self.appearance == SelectionAppearance::Picker && self.reserve_result_rows {
let reserved_height = self.max_visible_rows.min(usize::from(u16::MAX)) as u16;
measured_height.max(reserved_height)
} else {
measured_height
}
}
fn rows_width(&self, total_width: u16) -> u16 {
match self.appearance {
SelectionAppearance::Legacy => total_width.saturating_sub(/*rhs*/ 2),
SelectionAppearance::Picker => total_width,
}
}
fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) {
@@ -1184,12 +1307,13 @@ impl Renderable for ListSelectionView {
// When side-by-side is active, measure the list at the reduced width
// that accounts for the gap and side panel.
let effective_rows_width = if let Some(side_w) = self.side_layout_width(inner_width) {
Self::rows_width(width).saturating_sub(SIDE_CONTENT_GAP + side_w)
// Leave the preview's right inset outside the full-width focus row.
width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + side_w)
} else {
Self::rows_width(width)
self.rows_width(width)
};
// Measure wrapped height for up to MAX_POPUP_ROWS items.
// The shared measurement API reserves one column; compensate to match painted rows.
let rows = self.build_rows();
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width)
.with_description_layout(self.description_layout);
@@ -1197,20 +1321,41 @@ impl Renderable for ListSelectionView {
SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode(
&rows,
&self.state,
MAX_POPUP_ROWS,
effective_rows_width.saturating_add(1),
self.max_visible_rows,
effective_rows_width.saturating_add(/*rhs*/ 1),
column_width,
),
SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16,
SelectionRowDisplay::SingleLine => {
rows.len().clamp(/*min*/ 1, self.max_visible_rows) as u16
}
};
let header = self.active_header();
let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width);
let mut height = header.desired_height(inner_width);
height = height.saturating_add(tab_height + u16::from(tab_height > 0));
height = height.saturating_add(rows_height + 2 + self.header_gap);
let rows_height = self.result_area_height(rows_height);
let tab_height = tab_bar_height(
&self.tabs,
self.active_tab_idx.unwrap_or(/*default*/ 0),
inner_width,
self.tab_appearance(),
);
let mut height = self.header_height(inner_width);
let header_gap = if self.appearance == SelectionAppearance::Picker && height == 0 {
0
} else {
self.header_gap
};
let tab_gap = u16::from(
tab_height > 0
&& (self.appearance == SelectionAppearance::Legacy || self.is_searchable),
);
height = height.saturating_add(tab_height + tab_gap);
height = height.saturating_add(rows_height + 2 + header_gap);
if self.appearance == SelectionAppearance::Picker
&& self.picker_surface == PickerSurface::Panel
{
height = height.saturating_add(/*rhs*/ 1);
}
if self.is_searchable {
height = height.saturating_add(1);
height = height.saturating_add(/*rhs*/ 1);
}
// Side content: when the terminal is wide enough the panel sits beside
@@ -1230,14 +1375,15 @@ impl Renderable for ListSelectionView {
let note_lines = wrap_styled_line(note, note_width);
height = height.saturating_add(note_lines.len() as u16);
}
if self.active_footer_hint().is_some() {
height = height.saturating_add(1);
}
height = height.saturating_add(
self.footer_hint_lines(width.saturating_sub(/*rhs*/ 2))
.len() as u16,
);
height
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.rendered_item_count.set(0);
self.rendered_item_count.set(/*val*/ 0);
if area.height == 0 || area.width == 0 {
return;
}
@@ -1248,28 +1394,69 @@ impl Renderable for ListSelectionView {
.as_ref()
.map(|note| wrap_styled_line(note, note_width));
let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16);
let footer_rows = note_height + u16::from(self.active_footer_hint().is_some());
let hint_lines = self.footer_hint_lines(note_width);
let hint_height = hint_lines.len() as u16;
let inner_width = popup_content_width(area.width);
let header_height = self.header_height(inner_width);
let tab_height = tab_bar_height(
&self.tabs,
self.active_tab_idx.unwrap_or(/*default*/ 0),
inner_width,
self.tab_appearance(),
);
let mut footer_rows = note_height + hint_height;
if self.appearance == SelectionAppearance::Picker {
// Wrapped hints yield before the title, controls, and selected result.
let minimum_content = u16::from(self.picker_surface == PickerSurface::Panel)
+ header_height.min(/*other*/ 1)
+ tab_height
+ u16::from(self.is_searchable)
+ 1;
footer_rows = footer_rows.min(area.height.saturating_sub(minimum_content));
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area);
let outer_content_area = content_area;
// Paint the shared menu surface and then layout inside the returned inset.
let content_area = render_menu_surface(outer_content_area, buf);
let content_area = match self.appearance {
SelectionAppearance::Legacy => render_menu_surface(outer_content_area, buf),
SelectionAppearance::Picker => {
Self::clear_to_terminal_bg(buf, outer_content_area);
if self.picker_surface == PickerSurface::Panel {
ratatui::widgets::Block::default()
.style(crate::style::user_message_style())
.render(outer_content_area, buf);
}
// The list owns its vertical spacer rows so hints remain adjacent
// to results even when search, tabs, or a header are present.
let area = outer_content_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2));
if self.picker_surface == PickerSurface::Panel {
Rect::new(
area.x,
area.y.saturating_add(/*rhs*/ 1),
area.width,
area.height.saturating_sub(/*rhs*/ 1),
)
} else {
area
}
}
};
let inner_width = popup_content_width(outer_content_area.width);
let side_w = self.side_layout_width(inner_width);
// When side-by-side is active, shrink the list to make room.
let full_rows_width = Self::rows_width(outer_content_area.width);
let full_rows_width = self.rows_width(outer_content_area.width);
let effective_rows_width = if let Some(sw) = side_w {
full_rows_width.saturating_sub(SIDE_CONTENT_GAP + sw)
outer_content_area
.width
.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + sw)
} else {
full_rows_width
};
let header = self.active_header();
let header_height = header.desired_height(inner_width);
let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width);
let header = self.active_header(inner_width);
let rows = self.build_rows();
let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width)
.with_description_layout(self.description_layout);
@@ -1277,13 +1464,17 @@ impl Renderable for ListSelectionView {
SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode(
&rows,
&self.state,
MAX_POPUP_ROWS,
effective_rows_width.saturating_add(1),
self.max_visible_rows,
effective_rows_width.saturating_add(/*rhs*/ 1),
column_width,
),
SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16,
SelectionRowDisplay::SingleLine => {
rows.len().clamp(/*min*/ 1, self.max_visible_rows) as u16
}
};
let rows_height = self.result_area_height(rows_height);
// Stacked (fallback) side content height — only used when not side-by-side.
let stacked_side_h = if side_w.is_none() {
self.stacked_side_content().desired_height(inner_width)
@@ -1298,23 +1489,60 @@ impl Renderable for ListSelectionView {
tabs_area,
_,
search_area,
above_area,
list_area,
below_area,
_,
stacked_side_area,
] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(self.header_gap),
Constraint::Length(tab_height),
Constraint::Length(u16::from(tab_height > 0)),
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
Constraint::Length(stacked_gap),
Constraint::Length(stacked_side_h),
])
.areas(content_area);
] = if self.appearance == SelectionAppearance::Picker {
picker_areas(
content_area,
PickerLayoutSizes {
header: header_height,
header_gap: self.header_gap,
tabs: tab_height,
search: u16::from(self.is_searchable),
rows: rows_height,
side: stacked_side_h,
},
)
} else {
let [
header,
header_gap,
tabs,
tab_gap,
search,
rows,
side_gap,
side,
] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(self.header_gap),
Constraint::Length(tab_height),
Constraint::Length(u16::from(tab_height > 0)),
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
Constraint::Length(stacked_gap),
Constraint::Length(stacked_side_h),
])
.areas(content_area);
[
header,
header_gap,
tabs,
tab_gap,
search,
Rect::default(),
rows,
Rect::default(),
side_gap,
side,
]
};
// -- Header --
if header_area.height < header_height {
if self.appearance == SelectionAppearance::Legacy && header_area.height < header_height {
let [header_area, elision_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
header.render(header_area, buf);
@@ -1328,7 +1556,13 @@ impl Renderable for ListSelectionView {
// -- Tabs --
if tab_height > 0 {
render_tab_bar(&self.tabs, self.active_tab_idx.unwrap_or(0), tabs_area, buf);
render_tab_bar(
&self.tabs,
self.active_tab_idx.unwrap_or(/*default*/ 0),
tabs_area,
buf,
self.tab_appearance(),
);
}
// -- Search bar --
@@ -1348,7 +1582,7 @@ impl Renderable for ListSelectionView {
// -- List rows --
if list_area.height > 0 {
let render_area = Rect {
x: if rows.is_empty() {
x: if rows.is_empty() && self.appearance == SelectionAppearance::Legacy {
list_area.x
} else {
list_area.x.saturating_sub(2)
@@ -1378,6 +1612,21 @@ impl Renderable for ListSelectionView {
),
};
self.rendered_item_count.set(rendered_rows.items);
if self.appearance == SelectionAppearance::Picker
&& above_area.height > 0
&& below_area.height > 0
{
render_scroll_indicators(
Rect::new(
render_area.x,
above_area.y,
render_area.width,
below_area.bottom().saturating_sub(above_area.y),
),
buf,
rendered_rows,
);
}
}
// -- Side content (preview panel) --
@@ -1432,11 +1681,7 @@ impl Renderable for ListSelectionView {
if footer_area.height > 0 {
let [note_area, hint_area] = Layout::vertical([
Constraint::Length(note_height),
Constraint::Length(if self.active_footer_hint().is_some() {
1
} else {
0
}),
Constraint::Length(hint_height),
])
.areas(footer_area);
@@ -1461,14 +1706,21 @@ impl Renderable for ListSelectionView {
}
}
if let Some(hint) = self.active_footer_hint() {
if !hint_lines.is_empty() {
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
hint.clone().dim().render(hint_area, buf);
match self.appearance {
SelectionAppearance::Legacy => {
hint_lines[0].clone().dim().render(hint_area, buf)
}
SelectionAppearance::Picker => {
Paragraph::new(hint_lines).dim().render(hint_area, buf);
}
}
}
}
}
@@ -2474,13 +2726,13 @@ mod tests {
assert_eq!(view.selected_actual_idx(), Some(7));
let selected = view.state.selected_idx.expect("selection should be set");
assert!(view.state.scroll_top <= selected);
assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12));
assert!(selected < view.state.scroll_top + view.visible_rows(/*len*/ 12));
view.handle_key_event(KeyEvent::from(KeyCode::End));
assert_eq!(view.selected_actual_idx(), Some(7));
let selected = view.state.selected_idx.expect("selection should be set");
assert!(view.state.scroll_top <= selected);
assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12));
assert!(selected < view.state.scroll_top + view.visible_rows(/*len*/ 12));
}
#[test]
@@ -2999,3 +3251,7 @@ mod tests {
assert_eq!(cell.style().bg, Some(Color::Reset));
}
}
#[cfg(test)]
#[path = "selection_picker_tests.rs"]
mod picker_tests;
+4
View File
@@ -143,6 +143,8 @@ pub(crate) use footer::goal_status_indicator_line;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::ListSelectionView;
pub(crate) use list_selection_view::OnSelectionChangedCallback;
pub(crate) use list_selection_view::PickerSurface;
pub(crate) use list_selection_view::SelectionAppearance;
pub(crate) use list_selection_view::SelectionDescriptionLayout;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
@@ -174,8 +176,10 @@ pub(crate) use title_setup::preview_line_for_title_items;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
mod picker_style;
pub(crate) mod popup_consts;
mod scroll_state;
mod selection_picker_layout;
mod selection_popup_common;
mod selection_row_layout;
mod selection_tabs;
@@ -97,11 +97,9 @@ impl PendingInputPreview {
if !self.pending_steers.is_empty() {
let mut header = vec!["Messages to be submitted after next tool call".into()];
if let Some(interrupt_binding) = self.interrupt_binding {
header.extend(vec![
" (press ".dim(),
interrupt_binding.into(),
" to interrupt and send immediately)".dim(),
]);
header.push(" (press ".dim());
header.extend(interrupt_binding.spans());
header.push(" to interrupt and send immediately)".dim());
}
Self::push_section_header(&mut lines, width, Line::from(header));
@@ -171,14 +169,10 @@ impl PendingInputPreview {
&& !has_questions
&& let Some(edit_binding) = self.edit_binding
{
lines.push(
Line::from(vec![
" ".into(),
edit_binding.into(),
" edit last queued message".into(),
])
.dim(),
);
let mut hint = Line::from(" ");
hint.spans.extend(edit_binding.spans());
hint.spans.push(" edit last queued message".dim());
lines.push(hint);
}
Paragraph::new(lines).into()
@@ -0,0 +1,68 @@
//! Shared visual tokens and overflow hints for the picker component.
//!
//! Resolve RGB colors when rendering so terminal palette fallbacks stay effective.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use super::selection_popup_common::RenderedRows;
use crate::color::is_light;
pub(crate) use crate::style::selection_style;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use ratatui::style::Color;
/// The shared picker can blend into the transcript or form a shaded browser panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum PickerSurface {
#[default]
Terminal,
Panel,
}
pub(super) fn active_tab_style() -> Style {
let fallback = Style::default()
.fg(Color::Reset)
.bg(Color::Reset)
.bold()
.not_dim()
.underlined();
let Some(background) = default_bg() else {
return fallback;
};
let fill = best_color(if is_light(background) {
(220, 220, 220)
} else {
(76, 76, 76)
});
if fill == Color::Reset {
return fallback;
}
Style::default()
.fg(crate::style::readable_color_on(Color::Reset, Some(fill)))
.bg(fill)
.bold()
.not_dim()
.not_underlined()
}
/// Draw overflow hints against the background already painted beneath each cell.
pub(super) fn render_scroll_indicators(area: Rect, buf: &mut Buffer, rendered: RenderedRows) {
if area.height < 3 || area.width == 0 {
return;
}
for (visible, symbol, y) in [
(rendered.has_above, "↑", area.y),
(rendered.has_below, "↓", area.bottom() - 1),
] {
if visible {
Line::from(symbol)
.fg(crate::style::accent_color_on(Some(buf[(area.x, y)].bg)))
.render(Rect::new(area.x, y, /*width*/ 1, /*height*/ 1), buf);
}
}
}
+12 -19
View File
@@ -1,5 +1,6 @@
//! Shared popup-related constants for bottom pane widgets.
use ratatui::style::Stylize;
use ratatui::text::Line;
use crate::key_hint;
@@ -38,24 +39,16 @@ pub(crate) fn accept_cancel_hint_line(
cancel: Option<ShortcutHint>,
cancel_label: &'static str,
) -> Line<'static> {
match (accept, cancel) {
(Some(accept), Some(cancel)) => Line::from(vec![
"Press ".into(),
accept.into(),
format!(" {accept_label} or ").into(),
cancel.into(),
format!(" {cancel_label}").into(),
]),
(Some(accept), None) => Line::from(vec![
"Press ".into(),
accept.into(),
format!(" {accept_label}").into(),
]),
(None, Some(cancel)) => Line::from(vec![
"Press ".into(),
cancel.into(),
format!(" {cancel_label}").into(),
]),
(None, None) => Line::from(""),
let mut spans = Vec::new();
if let Some(accept) = accept {
spans.push("Press ".dim());
spans.extend(accept.spans());
spans.push(format!(" {accept_label}").dim());
}
if let Some(cancel) = cancel {
spans.push(if spans.is_empty() { "Press " } else { " or " }.dim());
spans.extend(cancel.spans());
spans.push(format!(" {cancel_label}").dim());
}
spans.into()
}
+4 -1
View File
@@ -62,7 +62,10 @@ impl BottomPane {
countdown.dim(),
])];
if let Some(binding) = self.pending_input_preview.edit_binding {
lines.push(Line::from(vec![" ".into(), binding.into(), " to answer".into()]).dim());
let mut hint = Line::from(" ");
hint.spans.extend(binding.spans());
hint.spans.push(" to answer".dim());
lines.push(hint);
}
Some(lines)
}
@@ -2512,7 +2512,7 @@ mod tests {
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec!["ctrl + j to submit answer", "esc to interrupt"]
vec!["ctrl+j to submit answer", "esc to interrupt"]
);
}
@@ -2521,21 +2521,21 @@ mod tests {
for (specs, expected_tips) in [
(
KeybindingsSpec::One(KeybindingSpec("ctrl-x enter".to_string())),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
vec!["ctrl+x enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-enter".to_string()),
KeybindingSpec("ctrl-x enter".to_string()),
]),
vec!["ctrl + enter to submit answer", "esc to interrupt"],
vec!["ctrl+enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-x enter".to_string()),
KeybindingSpec("ctrl-enter".to_string()),
]),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
vec!["ctrl+x enter to submit answer", "esc to interrupt"],
),
] {
let (tx, _rx) = test_sender();
@@ -3755,16 +3755,16 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(snapshot, @r"
insta::assert_snapshot!(snapshot, @"
Question 1/1 (1 unanswered)
Share details.
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
› Type your answer (optional)
ctrl + x enter to submit answer | esc to interrupt
ctrl+x enter to submit answer | esc to interrupt
");
}
@@ -10,4 +10,4 @@ expression: "render_snapshot(&overlay, area)"
ctrl + j to submit answer | esc to interrupt
ctrl+j to submit answer | esc to interrupt
@@ -0,0 +1,49 @@
//! Fit picker chrome before assigning result rows so short panels retain their controls.
//!
//! Optional gaps and previews yield first; headers, tabs, search, and at least one
//! result remain visible whenever the available height can accommodate them.
//! Overflow spacers are reserved separately from the minimum result content.
use ratatui::layout::Rect;
pub(super) struct PickerLayoutSizes {
pub header: u16,
pub header_gap: u16,
pub tabs: u16,
pub search: u16,
pub rows: u16,
pub side: u16,
}
pub(super) fn picker_areas(area: Rect, sizes: PickerLayoutSizes) -> [Rect; 10] {
let tabs = sizes.tabs.min(area.height);
let search = sizes.search.min(area.height.saturating_sub(tabs));
let minimum_rows = sizes.rows.min(if tabs > 0 { 1 } else { 3 });
let header = sizes
.header
.min(area.height.saturating_sub(tabs + search + minimum_rows));
let mut remaining = area.height.saturating_sub(header + tabs + search);
let indicators = u16::from(remaining >= minimum_rows + 2);
remaining = remaining.saturating_sub(indicators * 2);
let minimum_rows = sizes.rows.min(/*other*/ 3).min(remaining);
let header_gap = if header > 0 {
sizes.header_gap.min(remaining.saturating_sub(minimum_rows))
} else {
0
};
remaining = remaining.saturating_sub(header_gap);
let tab_gap = u16::from(tabs > 0 && search > 0 && remaining > minimum_rows);
remaining = remaining.saturating_sub(tab_gap);
let side = sizes.side.min(remaining.saturating_sub(minimum_rows + 1));
let side_gap = u16::from(side > 0);
let rows = sizes.rows.min(remaining.saturating_sub(side + side_gap));
let mut y = area.y;
[
header, header_gap, tabs, tab_gap, search, indicators, rows, indicators, side_gap, side,
]
.map(|height| {
let rect = Rect::new(area.x, y, area.width, height);
y = y.saturating_add(height);
rect
})
}
@@ -0,0 +1,183 @@
//! Opt-in panels retain controls and page by the results that actually fit.
use super::*;
use crate::app_event::AppEvent;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use tokio::sync::mpsc::unbounded_channel;
fn browser() -> ListSelectionView {
let (tx, _rx) = unbounded_channel::<AppEvent>();
ListSelectionView::new(
SelectionViewParams {
appearance: SelectionAppearance::Picker,
picker_surface: PickerSurface::Panel,
max_visible_rows: 24,
title: Some("Keymap".to_owned()),
subtitle: Some("All configurable shortcuts.".to_owned()),
is_searchable: true,
search_placeholder: Some("Type to search shortcuts".to_owned()),
footer_hint: Some("left/right group · enter edit · esc close".into()),
row_display: SelectionRowDisplay::SingleLine,
tabs: ["All", "Common", "Customized (3)", "Unbound (4)", "App"]
.into_iter()
.map(|label| SelectionTab {
id: label.to_owned(),
label: label.to_owned(),
header: Box::new(()),
items: (0..30)
.map(|index| SelectionItem {
name: format!("Action {index:02}"),
description: Some(format!("Ctrl+{index}")),
search_value: Some(format!("Action {index:02}")),
..Default::default()
})
.collect(),
})
.collect(),
..Default::default()
},
AppEventSender::new(tx),
crate::keymap::RuntimeKeymap::defaults().list,
)
}
fn render_browser(view: &ListSelectionView, width: u16, height: u16) -> (String, Buffer) {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let text = (0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
(text, buf)
}
#[test]
fn short_browser_keeps_header_tabs_search_and_footer() {
let view = browser();
let mut snapshots = Vec::new();
for (width, height) in [(48, 6), (48, 7), (48, 13), (24, 6), (24, 7)] {
let (text, _) = render_browser(&view, width, height);
assert!(text.contains("Keymap"));
assert!(text.contains("Common"));
assert!(text.contains("Type to search"));
assert!(text.contains("left/right group"));
assert!(view.rendered_item_count() > 0);
snapshots.push(format!("{width}×{height}:\n{text}"));
}
insta::assert_snapshot!(snapshots.join("\n---\n"));
}
#[test]
fn browser_page_navigation_uses_rows_that_fit() {
let mut view = browser();
let (_, initial_buf) = render_browser(&view, /*width*/ 60, /*height*/ 18);
let visible = view.rendered_item_count();
assert!(visible < view.max_visible_rows);
view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
let (text, buf) = render_browser(&view, /*width*/ 60, /*height*/ 18);
insta::assert_snapshot!(text);
assert_eq!(view.selected_actual_idx(), Some(visible));
let selected_row = text
.lines()
.position(|line| line.starts_with('›'))
.expect("focused row");
assert_eq!(
buf[(0, selected_row as u16)].bg,
selection_style().bg.unwrap()
);
assert_eq!(buf[(0, 1)].bg, initial_buf[(0, 1)].bg);
}
#[test]
fn shrinking_the_browser_keeps_selection_visible_without_navigation() {
for row_display in [
SelectionRowDisplay::SingleLine,
SelectionRowDisplay::Wrapped,
] {
let mut view = browser();
view.row_display = row_display;
view.state.selected_idx = Some(7);
view.state.scroll_top = 0;
render_browser(&view, /*width*/ 60, /*height*/ 24);
let initial_count = view.rendered_item_count();
assert!(initial_count >= 8);
let (text, _) = render_browser(&view, /*width*/ 60, /*height*/ 13);
assert!(view.rendered_item_count() < initial_count);
assert_eq!(view.selected_actual_idx(), Some(7));
let focused = text
.lines()
.find(|line| line.starts_with('›'))
.expect("focused row");
assert!(focused.contains("Action 07"), "{text}");
}
}
#[test]
fn overflow_arrows_follow_the_painted_picker_surface() {
for (fg, bg) in [
((32, 32, 32), (255, 255, 255)),
((240, 240, 240), (24, 24, 24)),
((240, 240, 240), (80, 80, 80)),
((32, 32, 32), (130, 130, 130)),
] {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors { fg, bg },
|| {
for surface in [PickerSurface::Panel, PickerSurface::Terminal] {
let mut view = browser();
view.picker_surface = surface;
view.state.selected_idx = Some(15);
view.state.scroll_top = 12;
let (text, buf) = render_browser(&view, /*width*/ 60, /*height*/ 18);
let selected_y = text
.lines()
.position(|line| line.starts_with('›'))
.expect("focused row") as u16;
let selected_background = selection_style().bg.unwrap();
assert_eq!(
(0..buf.area.width)
.map(|x| buf[(x, selected_y)].bg)
.collect::<Vec<_>>(),
vec![selected_background; usize::from(buf.area.width)],
);
let background = match surface {
PickerSurface::Panel => crate::style::user_message_style().bg.unwrap(),
PickerSurface::Terminal => Color::Reset,
};
let arrows = (0..buf.area.height)
.filter(|&y| matches!(buf[(0, y)].symbol(), "↑" | "↓"))
.map(|y| (y, buf[(0, y)].clone(), buf[(1, y)].clone()))
.collect::<Vec<_>>();
assert_eq!(
arrows
.iter()
.map(|(_, cell, _)| cell.symbol())
.collect::<Vec<_>>(),
vec!["↑", "↓"],
);
for (y, cell, adjacent) in &arrows {
assert_ne!(*y, selected_y);
assert_ne!(cell.bg, selected_background);
assert_eq!(
(cell.fg, cell.bg, adjacent.bg),
(
crate::style::accent_color_on(Some(background)),
background,
background,
),
);
}
}
},
);
}
}
@@ -2,6 +2,8 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -31,6 +33,8 @@ use super::selection_row_layout::wrap_stacked_row;
#[derive(Default)]
pub(crate) struct GenericDisplayRow {
pub name: String,
/// Optional full-width selection treatment; other rows use the shared text accent.
pub selection_style: Option<Style>,
pub name_prefix_spans: Vec<Span<'static>>,
pub display_shortcut: Option<ShortcutHint>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
@@ -326,11 +330,36 @@ fn wrap_row_lines(
wrap_standard_row(row, desc_col, width, description_layout)
}
fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disabled: bool) {
fn apply_row_state_style(
lines: &mut [Line<'static>],
selected: bool,
is_disabled: bool,
selection_style: Option<Style>,
) {
if selected {
for line in lines.iter_mut() {
if let Some(style) = selection_style {
line.style = style;
}
line.spans.iter_mut().for_each(|span| {
span.style = accent_style();
let Some(selected) = selection_style else {
span.style = accent_style();
return;
};
let secondary = span.style.add_modifier.contains(Modifier::DIM);
let foreground = span.style.fg.or(selected.fg).unwrap_or_default();
span.style = selected.patch(span.style).not_dim();
span.style.bg = selected.bg;
if selected.add_modifier.contains(Modifier::REVERSED) {
span.style.fg = selected.fg;
span.style = span.style.reversed();
} else {
span.style.fg = Some(crate::style::readable_color_on(foreground, selected.bg));
span.style = span.style.not_reversed();
}
if secondary {
span.style = span.style.not_bold();
}
});
}
}
@@ -448,14 +477,15 @@ fn adjust_start_for_wrapped_selection_visibility(
start_idx
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// Returns the number of terminal lines actually rendered (including the
/// single-line empty placeholder when shown).
/// Counts painted rows and items and reports content hidden outside the viewport.
/// The line count includes the single-line empty placeholder when shown.
#[derive(Clone, Copy, Default)]
pub(crate) struct RenderedRows {
pub(crate) lines: u16,
pub(crate) items: usize,
pub(crate) has_above: bool,
/// Includes a description whose final wrapped lines were clipped.
pub(crate) has_below: bool,
}
fn render_rows_inner(
@@ -475,6 +505,7 @@ fn render_rows_inner(
return RenderedRows {
lines: u16::from(area.height > 0),
items: 0,
..Default::default()
};
}
@@ -509,6 +540,7 @@ fn render_rows_inner(
let mut cur_y = area.y;
let mut rendered_lines: u16 = 0;
let mut rendered_items = 0;
let mut clipped = false;
for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
if cur_y >= area.y + area.height {
break;
@@ -516,10 +548,12 @@ fn render_rows_inner(
let mut wrapped =
wrap_row_lines(row, desc_col, area.width, column_width.description_layout);
clipped |= wrapped.len() > usize::from(area.bottom().saturating_sub(cur_y));
apply_row_state_style(
&mut wrapped,
Some(i) == state.selected_idx && !row.is_disabled,
row.is_disabled,
row.selection_style,
);
// Render the wrapped lines.
@@ -549,6 +583,8 @@ fn render_rows_inner(
RenderedRows {
lines: rendered_lines,
items: rendered_items,
has_above: start_idx > 0,
has_below: clipped || start_idx + rendered_items < rows_all.len(),
}
}
@@ -651,6 +687,7 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
return RenderedRows {
lines: u16::from(area.height > 0),
items: 0,
..Default::default()
};
}
@@ -685,16 +722,12 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
}
let mut full_line = build_full_line(row, desc_col, column_width.description_layout);
if Some(i) == state.selected_idx && !row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = accent_style();
});
}
if row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
apply_row_state_style(
std::slice::from_mut(&mut full_line),
Some(i) == state.selected_idx && !row.is_disabled,
row.is_disabled,
row.selection_style,
);
let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize);
full_line.render(
@@ -713,6 +746,8 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
RenderedRows {
lines: rendered_lines,
items: rendered_lines as usize,
has_above: start_idx > 0,
has_below: start_idx + usize::from(rendered_lines) < rows_all.len(),
}
}
@@ -812,6 +847,7 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
#[test]
@@ -944,6 +980,109 @@ mod tests {
");
}
#[test]
fn selection_preserves_semantic_spans_and_secondary_hierarchy() {
for (fg, bg) in [
((32, 32, 32), (255, 255, 255)),
((230, 230, 230), (18, 20, 30)),
] {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors { fg, bg },
|| {
for selected in [
crate::style::selection_style(),
Style::default()
.fg(Color::Reset)
.bg(Color::Reset)
.bold()
.reversed(),
] {
for single_line in [true, false] {
let rows = [GenericDisplayRow {
name_prefix_spans: vec![
"L".underlined(),
"M ".into(),
"Ready ".green().italic(),
],
name: "provider".into(),
description: Some("secondary details".into()),
selection_style: Some(selected),
..Default::default()
}];
let state = ScrollState {
selected_idx: Some(0),
scroll_top: 0,
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
if single_line {
render_rows_single_line_with_col_width_mode(
area,
&mut buffer,
&rows,
&state,
/*max_results*/ 1,
"",
ColumnWidthConfig::default(),
);
} else {
render_rows(
area,
&mut buffer,
&rows,
&state,
/*max_results*/ 1,
"",
);
}
let line = buffer
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
let status = &buffer[(3, 0)];
let description_x =
line.find("secondary").expect("visible secondary text") as u16;
assert_eq!(
(
buffer[(0, 0)].modifier,
status.modifier,
buffer[(description_x, 0)].modifier
),
(
Modifier::BOLD
| Modifier::UNDERLINED
| (selected.add_modifier & Modifier::REVERSED),
Modifier::BOLD
| Modifier::ITALIC
| (selected.add_modifier & Modifier::REVERSED),
selected.add_modifier & Modifier::REVERSED,
)
);
assert_eq!(
status.fg,
if selected.add_modifier.contains(Modifier::REVERSED) {
Color::Reset
} else {
Color::Green
}
);
assert_eq!(buffer[(0, 0)].fg, selected.fg.unwrap());
assert!(
buffer
.content
.iter()
.all(|cell| cell.bg == selected.bg.unwrap())
);
}
}
},
);
}
}
#[test]
fn selected_rows_use_the_shared_accent_style() {
let rows = vec![GenericDisplayRow {
@@ -120,7 +120,7 @@ fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec<Span<'sta
fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec<Span<'static>>) {
if let Some(display_shortcut) = row.display_shortcut {
spans.push(" (".into());
spans.push(display_shortcut.into());
spans.extend(display_shortcut.spans());
spans.push(")".into());
}
}
+96 -2
View File
@@ -1,3 +1,7 @@
//! Tab headers for selection lists, including a single-row filled picker variant.
//!
//! Filled tabs always retain the active tab and reserve arrows for hidden neighbors.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Styled;
@@ -6,13 +10,21 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use crate::style::accent_style;
use super::SelectionItem;
use super::picker_style::active_tab_style;
const TAB_GAP_WIDTH: usize = 2;
#[derive(Clone, Copy)]
pub(super) enum TabAppearance {
Legacy,
Filled,
}
pub(crate) struct SelectionTab {
pub(crate) id: String,
pub(crate) label: String,
@@ -20,22 +32,39 @@ pub(crate) struct SelectionTab {
pub(crate) items: Vec<SelectionItem>,
}
pub(crate) fn tab_bar_height(tabs: &[SelectionTab], active_idx: usize, width: u16) -> u16 {
pub(super) fn tab_bar_height(
tabs: &[SelectionTab],
active_idx: usize,
width: u16,
appearance: TabAppearance,
) -> u16 {
if tabs.is_empty() {
return 0;
}
if matches!(appearance, TabAppearance::Filled) {
return u16::from(width > 0);
}
tab_bar_lines(tabs, active_idx, width)
.len()
.try_into()
.unwrap_or(u16::MAX)
}
pub(crate) fn render_tab_bar(
pub(super) fn render_tab_bar(
tabs: &[SelectionTab],
active_idx: usize,
area: Rect,
buf: &mut Buffer,
appearance: TabAppearance,
) {
if matches!(appearance, TabAppearance::Filled) {
let labels = tabs
.iter()
.map(|tab| tab.label.as_str())
.collect::<Vec<_>>();
render_filled_tab_bar(&labels, active_idx, area, buf);
return;
}
for (offset, line) in tab_bar_lines(tabs, active_idx, area.width)
.into_iter()
.take(area.height as usize)
@@ -53,6 +82,67 @@ pub(crate) fn render_tab_bar(
}
}
/// Render a fixed-height tab strip while keeping the active tab visible.
fn render_filled_tab_bar(labels: &[&str], active_idx: usize, area: Rect, buf: &mut Buffer) {
if labels.is_empty() || area.is_empty() {
return;
}
let active_idx = active_idx.min(labels.len() - 1);
let widths = labels
.iter()
.map(|label| Line::from(*label).width() + 2)
.collect::<Vec<_>>();
let occupied = |start: usize, end: usize| {
widths[start..end].iter().sum::<usize>() + end - start - 1
+ usize::from(start > 0) * 2
+ usize::from(end < labels.len()) * 2
};
let mut start = active_idx;
let mut end = active_idx + 1;
while start > 0 && occupied(start - 1, end) <= usize::from(area.width) {
start -= 1;
}
while end < labels.len() && occupied(start, end + 1) <= usize::from(area.width) {
end += 1;
}
// Tiny strips prioritize a readable active label over navigation hints.
let show_left = start > 0 && area.width >= 5;
let show_right = end < labels.len() && area.width >= 7;
let mut x = area.x;
if show_left {
Line::from("‹")
.dim()
.render(Rect::new(x, area.y, /*width*/ 1, /*height*/ 1), buf);
x += 2;
}
let right = area.right().saturating_sub(u16::from(show_right) * 2);
for idx in start..end {
let width = widths[idx].min(usize::from(right.saturating_sub(x))) as u16;
let line = Line::from(format!(" {} ", labels[idx]));
let line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
let line = if idx == active_idx {
line.style(active_tab_style())
} else {
line.dim()
};
let tab = Rect::new(x, area.y, width, /*height*/ 1);
line.render(tab, buf);
x = x.saturating_add(width).saturating_add(/*rhs*/ 1);
}
if show_right {
Line::from("›").dim().render(
Rect::new(
area.right() - 1,
area.y,
/*width*/ 1,
/*height*/ 1,
),
buf,
);
}
}
fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec<Line<'static>> {
if tabs.is_empty() {
return Vec::new();
@@ -104,3 +194,7 @@ fn tab_unit(label: &str, active: bool) -> Vec<Span<'static>> {
vec![label.to_string().dim()]
}
}
#[cfg(test)]
#[path = "selection_tabs_tests.rs"]
mod tests;
@@ -0,0 +1,85 @@
//! Filled tabs preserve active visibility and Unicode clipping on narrow terminals.
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
fn rendered_strip(labels: &[&str], active_idx: usize, width: u16) -> (String, Buffer) {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
let mut buf = Buffer::empty(area);
buf.set_style(
area,
Style::default().red().bg(Color::Yellow).underlined().dim(),
);
render_filled_tab_bar(labels, active_idx, area, &mut buf);
let text = (0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>();
(text.trim_end().to_owned(), buf)
}
#[test]
fn filled_tabs_window_around_active_tab() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (30, 30, 30),
bg: (255, 255, 255),
},
|| {
let labels = [
"All",
"Common",
"Customized (3)",
"Unbound (4)",
"App",
"Composer",
"Debug",
];
let strips =
[0, 2, 6].map(|active_idx| rendered_strip(&labels, active_idx, /*width*/ 30).0);
insta::assert_snapshot!(strips.join("\n"));
for active_idx in 0..labels.len() {
let (text, buf) = rendered_strip(&labels, active_idx, /*width*/ 30);
assert!(text.contains(labels[active_idx]), "{text}");
let focused = buf
.content()
.iter()
.filter(|cell| cell.bg == active_tab_style().bg.unwrap())
.map(|cell| {
assert_eq!(cell.modifier, Modifier::BOLD);
cell.symbol()
})
.collect::<String>();
assert_eq!(focused, format!(" {} ", labels[active_idx]));
}
},
);
}
#[test]
fn filled_tabs_truncate_unicode_without_hiding_active_tab() {
let labels = ["All", "文件系统 plugins", "Debug"];
let strips = [1, 4, 9, 16].map(|width| rendered_strip(&labels, /*active_idx*/ 1, width).0);
insta::assert_snapshot!(strips.join("\n"));
}
// Non-Windows tests have an unprobed default palette; Windows retains its console probe.
#[cfg(not(windows))]
#[test]
fn filled_tab_unknown_palette_uses_readable_underlined_defaults() {
let (_, buf) = rendered_strip(&["All"], /*active_idx*/ 0, /*width*/ 5);
assert_eq!(
buf.content()
.iter()
.map(|cell| (cell.fg, cell.bg, cell.modifier))
.collect::<Vec<_>>(),
vec![
(
Color::Reset,
Color::Reset,
Modifier::BOLD | Modifier::UNDERLINED
);
5
],
);
}
@@ -143,6 +143,7 @@ impl SkillPopup {
_ => None,
};
GenericDisplayRow {
selection_style: None,
name,
name_prefix_spans: Vec::new(),
match_indices: indices,
@@ -594,8 +594,8 @@ mod tests {
let view = SkillsToggleView::new(Vec::new(), tx, keymap);
let rendered = render_lines(&view, /*width*/ 72);
assert!(rendered.contains("ctrl + t"));
assert!(rendered.contains("ctrl + x"));
assert!(rendered.contains("ctrl+t"));
assert!(rendered.contains("ctrl+x"));
assert!(!rendered.contains("enter"));
assert!(!rendered.contains("esc"));
}
@@ -24,14 +24,18 @@ Buffer {
x: 57, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 28, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 54, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 45, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 48, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 51, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 8, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 13, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 28, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 31, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -10,13 +10,13 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" ctrl + j for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" ctrl+j for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" ← for agents (empty prompt) "
" "
" customize shortcuts with /keymap "
@@ -10,12 +10,12 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" ctrl + x enter for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" ctrl+x enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ctrl + c again to quit "
" ctrl+c again to quit "
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ctrl + c again to quit "
" ctrl+c again to quit "
@@ -10,12 +10,12 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" shift + enter for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift+enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc again to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -10,12 +10,12 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift+enter for newline tab to queue message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -19,9 +19,10 @@ Buffer {
x: 2, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: DIM,
x: 26, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 5, fg: Rgb(110, 112, 108), bg: Reset, underline: Reset, modifier: BOLD,
x: 3, y: 5, fg: Rgb(118, 119, 117), bg: Reset, underline: Reset, modifier: NONE,
x: 17, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 29, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 5, fg: Rgb(118, 119, 117), bg: Reset, underline: Reset, modifier: NONE,
x: 46, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -10,7 +10,7 @@ Buffer {
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 52, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
@@ -11,7 +11,7 @@ Buffer {
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 65, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
@@ -9,7 +9,7 @@ Buffer {
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 68, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to quit "
" ctrl+c again to quit "
@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to quit "
" ctrl+c again to quit "
@@ -2,12 +2,12 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands ! for shell commands "
" ctrl + j for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" shift + tab to change mode ctrl + t to view transcript "
" / for commands ! for shell commands "
" ctrl+j for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" shift+tab to change mode ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -2,12 +2,12 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands ! for shell commands "
" ctrl + j for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to interrupt "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" ctrl+j for newline tab to queue message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc esc to edit previous message "
" ctrl+r search history ctrl+c to interrupt "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -2,12 +2,12 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands ! for shell commands "
" shift + enter for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift+enter for newline tab to submit message "
" @ for file paths ctrl+v to paste images "
" ctrl+g to edit in external editor esc again to edit previous message "
" ctrl+r search history ctrl+c to exit "
" ⌥+, reasoning down ⌥+. reasoning up "
" ctrl+t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -1,33 +1,32 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1355
expression: "(backend.to_string(), style_runs)"
---
(
"\" voice ● listen ctrl+m mute \"\n",
"\" voice ● listen · ctrl+m mute \"\n",
[
(
0,
Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset),
),
(
3,
2,
Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold(),
),
(
8,
7,
Style::new().red().bg(Color::Reset).underline_color(Color::Reset),
),
(
10,
9,
Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset),
),
(
20,
19,
Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold(),
),
(
26,
25,
Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset),
),
],
@@ -0,0 +1,23 @@
---
source: tui/src/bottom_pane/selection_picker_tests.rs
assertion_line: 79
expression: text
---
Keymap
All configurable shortcuts.
All Common Customized (3) Unbound (4) App
Type to search shortcuts
↑
Action 01 Ctrl+1
Action 02 Ctrl+2
Action 03 Ctrl+3
Action 04 Ctrl+4
Action 05 Ctrl+5
Action 06 Ctrl+6
Action 07 Ctrl+7
› Action 08 Ctrl+8
↓
left/right group · enter edit · esc close

Some files were not shown because too many files have changed in this diff Show More