Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83741d7203 | ||
|
|
b618b7dd27 | ||
|
|
f37ff681ea |
@@ -1179,6 +1179,10 @@ impl App {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight.clone(),
|
||||
attached,
|
||||
// The active tab's session, not the displayed one: a preview is
|
||||
// shown in the pane but never becomes what the operator picked,
|
||||
// so it must not move this fill (`docs/PREVIEW.md`).
|
||||
pane_session: self.state.active_session(),
|
||||
};
|
||||
let shown_sid = self.state.displayed_session();
|
||||
let previewing = self.state.preview.is_some();
|
||||
@@ -1269,6 +1273,7 @@ fn visible_sidebar_order(
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
}
|
||||
.rendered_items_with_collapsed(collapsed)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod matcher;
|
||||
pub mod overlays;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
//! Pure match-and-rank function for the `ctrl-k` command bar.
|
||||
//!
|
||||
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
|
||||
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
|
||||
//! and scores as `web/src/matcher.ts` for the same input.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CandidateKind {
|
||||
Session,
|
||||
Command,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MatchFieldName {
|
||||
Name,
|
||||
Directory,
|
||||
Command,
|
||||
Tool,
|
||||
Label,
|
||||
Path,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionCandidate {
|
||||
pub name: String,
|
||||
pub directory: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub tool: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandCandidate {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirectoryCandidate {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MatchCandidate {
|
||||
Session(SessionCandidate),
|
||||
Command(CommandCandidate),
|
||||
Directory(DirectoryCandidate),
|
||||
}
|
||||
|
||||
pub type MatchRange = (usize, usize);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MatchResult<'c> {
|
||||
pub candidate: &'c MatchCandidate,
|
||||
pub score: i32,
|
||||
pub field: Option<MatchFieldName>,
|
||||
pub ranges: Vec<MatchRange>,
|
||||
}
|
||||
|
||||
struct Field<'c> {
|
||||
name: MatchFieldName,
|
||||
text: &'c str,
|
||||
weight: i32,
|
||||
}
|
||||
|
||||
impl MatchCandidate {
|
||||
fn kind(&self) -> CandidateKind {
|
||||
match self {
|
||||
MatchCandidate::Session(_) => CandidateKind::Session,
|
||||
MatchCandidate::Command(_) => CandidateKind::Command,
|
||||
MatchCandidate::Directory(_) => CandidateKind::Directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<Field<'_>> {
|
||||
match self {
|
||||
MatchCandidate::Session(session) => {
|
||||
let mut fields = vec![Field {
|
||||
name: MatchFieldName::Name,
|
||||
text: &session.name,
|
||||
weight: 0,
|
||||
}];
|
||||
if let Some(directory) = &session.directory {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Directory,
|
||||
text: directory,
|
||||
weight: -24,
|
||||
});
|
||||
}
|
||||
if let Some(command) = &session.command {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Command,
|
||||
text: command,
|
||||
weight: -32,
|
||||
});
|
||||
}
|
||||
if let Some(tool) = &session.tool {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Tool,
|
||||
text: tool,
|
||||
weight: -40,
|
||||
});
|
||||
}
|
||||
fields
|
||||
}
|
||||
MatchCandidate::Command(command) => vec![Field {
|
||||
name: MatchFieldName::Label,
|
||||
text: &command.label,
|
||||
weight: 0,
|
||||
}],
|
||||
MatchCandidate::Directory(directory) => vec![Field {
|
||||
name: MatchFieldName::Path,
|
||||
text: &directory.path,
|
||||
weight: 0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_order(kind: CandidateKind) -> u8 {
|
||||
match kind {
|
||||
CandidateKind::Session => 0,
|
||||
CandidateKind::Command => 1,
|
||||
CandidateKind::Directory => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_char(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
fn lower_chars(chars: &[char]) -> Vec<char> {
|
||||
chars.iter().copied().map(lower_char).collect()
|
||||
}
|
||||
|
||||
fn normalize_query(query: &str) -> Vec<char> {
|
||||
query
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
|
||||
|
||||
fn boundary_bonus(text: &[char], position: usize) -> i32 {
|
||||
if position == 0 {
|
||||
return 18;
|
||||
}
|
||||
|
||||
let previous = text[position - 1];
|
||||
if previous == ' ' || SEPARATORS.contains(&previous) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
let current = text[position];
|
||||
if current.is_uppercase() && previous.is_lowercase() {
|
||||
return 12;
|
||||
}
|
||||
|
||||
let is_digit = current.is_ascii_digit();
|
||||
let previous_is_digit = previous.is_ascii_digit();
|
||||
if is_digit && !previous_is_digit {
|
||||
return 12;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
|
||||
let gap = position as isize - previous_position - 1;
|
||||
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Embedding {
|
||||
score: i32,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
|
||||
let Some(incumbent) = incumbent else {
|
||||
return true;
|
||||
};
|
||||
if candidate.score != incumbent.score {
|
||||
return candidate.score > incumbent.score;
|
||||
}
|
||||
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
|
||||
if c != i {
|
||||
return c < i;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The dynamic program over `(query index, text position)` described in
|
||||
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
|
||||
/// `mtu` against `maestro-tui`), so every reachable position at each query
|
||||
/// index carries its best score and position vector forward.
|
||||
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
|
||||
let lower_text = lower_chars(text);
|
||||
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (query_index, &qc) in query.iter().enumerate() {
|
||||
let mut states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (position, &text_char) in lower_text.iter().enumerate() {
|
||||
if text_char != qc {
|
||||
continue;
|
||||
}
|
||||
|
||||
if query_index == 0 {
|
||||
states.insert(
|
||||
position,
|
||||
Embedding {
|
||||
score: contribution(text, position, -1),
|
||||
positions: vec![position],
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (&previous_position, previous) in previous_states.iter() {
|
||||
if previous_position >= position {
|
||||
continue;
|
||||
}
|
||||
let mut positions = previous.positions.clone();
|
||||
positions.push(position);
|
||||
let candidate = Embedding {
|
||||
score: previous.score
|
||||
+ contribution(text, position, previous_position as isize),
|
||||
positions,
|
||||
};
|
||||
if is_better_embedding(&candidate, states.get(&position)) {
|
||||
states.insert(position, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if states.is_empty() {
|
||||
return None;
|
||||
}
|
||||
previous_states = states;
|
||||
}
|
||||
|
||||
let mut best: Option<Embedding> = None;
|
||||
for embedding in previous_states.into_values() {
|
||||
if is_better_embedding(&embedding, best.as_ref()) {
|
||||
best = Some(embedding);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
|
||||
let mut ranges: Vec<MatchRange> = Vec::new();
|
||||
for &position in positions {
|
||||
if let Some(last) = ranges.last_mut() {
|
||||
if last.1 == position {
|
||||
last.1 = position + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ranges.push((position, position + 1));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
fn compact(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Ranked<'c> {
|
||||
candidate: &'c MatchCandidate,
|
||||
score: i32,
|
||||
field: Option<MatchFieldName>,
|
||||
ranges: Vec<MatchRange>,
|
||||
matched_text: String,
|
||||
input_index: usize,
|
||||
}
|
||||
|
||||
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
|
||||
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
|
||||
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
|
||||
let normalized_query = normalize_query(query);
|
||||
|
||||
if normalized_query.is_empty() {
|
||||
return candidates
|
||||
.iter()
|
||||
.map(|candidate| MatchResult {
|
||||
candidate,
|
||||
score: 0,
|
||||
field: None,
|
||||
ranges: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<Ranked<'c>> = Vec::new();
|
||||
|
||||
for (input_index, candidate) in candidates.iter().enumerate() {
|
||||
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
|
||||
|
||||
for field in candidate.fields() {
|
||||
let text_chars: Vec<char> = field.text.chars().collect();
|
||||
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let compact_text = compact(field.text);
|
||||
let normalized_query_str: String = normalized_query.iter().collect();
|
||||
let adjustment = if compact_text == normalized_query_str {
|
||||
64
|
||||
} else if compact_text.starts_with(&normalized_query_str) {
|
||||
32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let score = embedding.score + adjustment + field.weight;
|
||||
|
||||
let is_better = match &best {
|
||||
None => true,
|
||||
Some((_, _, best_score, _)) => score > *best_score,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((field.name, field.text, score, embedding.positions));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((field_name, field_text, score, positions)) = best {
|
||||
results.push(Ranked {
|
||||
candidate,
|
||||
score,
|
||||
field: Some(field_name),
|
||||
ranges: to_ranges(&positions),
|
||||
matched_text: field_text.to_string(),
|
||||
input_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
|
||||
.then_with(|| {
|
||||
a.matched_text
|
||||
.chars()
|
||||
.count()
|
||||
.cmp(&b.matched_text.chars().count())
|
||||
})
|
||||
.then_with(|| a.matched_text.cmp(&b.matched_text))
|
||||
.then_with(|| a.input_index.cmp(&b.input_index))
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|ranked| MatchResult {
|
||||
candidate: ranked.candidate,
|
||||
score: ranked.score,
|
||||
field: ranked.field,
|
||||
ranges: ranked.ranges,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session(
|
||||
name: &str,
|
||||
directory: Option<&str>,
|
||||
command: Option<&str>,
|
||||
tool: Option<&str>,
|
||||
) -> MatchCandidate {
|
||||
MatchCandidate::Session(SessionCandidate {
|
||||
name: name.to_string(),
|
||||
directory: directory.map(str::to_string),
|
||||
command: command.map(str::to_string),
|
||||
tool: tool.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn command(label: &str) -> MatchCandidate {
|
||||
MatchCandidate::Command(CommandCandidate {
|
||||
label: label.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn directory(path: &str) -> MatchCandidate {
|
||||
MatchCandidate::Directory(DirectoryCandidate {
|
||||
path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn label(candidate: &MatchCandidate) -> String {
|
||||
match candidate {
|
||||
MatchCandidate::Session(s) => format!("session:{}", s.name),
|
||||
MatchCandidate::Command(c) => format!("command:{}", c.label),
|
||||
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
|
||||
}
|
||||
}
|
||||
|
||||
fn positions(result: &MatchResult) -> Vec<usize> {
|
||||
result
|
||||
.ranges
|
||||
.iter()
|
||||
.flat_map(|&(start, end)| start..end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn expect_results(
|
||||
query: &str,
|
||||
candidates: &[MatchCandidate],
|
||||
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
|
||||
) {
|
||||
let results = match_and_rank(query, candidates);
|
||||
|
||||
let actual: Vec<(String, i32)> = results
|
||||
.iter()
|
||||
.map(|r| (label(r.candidate), r.score))
|
||||
.collect();
|
||||
let expected_labels: Vec<(String, i32)> = expected
|
||||
.iter()
|
||||
.map(|(l, s, _, _)| (l.to_string(), *s))
|
||||
.collect();
|
||||
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
|
||||
|
||||
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
|
||||
let expected_fields: Vec<Option<MatchFieldName>> =
|
||||
expected.iter().map(|(_, _, f, _)| *f).collect();
|
||||
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
|
||||
|
||||
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
|
||||
let expected_positions: Vec<Vec<usize>> =
|
||||
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
|
||||
assert_eq!(
|
||||
actual_positions, expected_positions,
|
||||
"positions for query {query:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_empty_query_keeps_input_order() {
|
||||
expect_results(
|
||||
"",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
("session:kill", 0, None, &[]),
|
||||
("session:killer-app", 0, None, &[]),
|
||||
("command:Kill selected session", 0, None, &[]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
|
||||
expect_results(
|
||||
"kill",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:kill",
|
||||
178,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"session:killer-app",
|
||||
146,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_path_segment_boundaries_beat_mid_segment() {
|
||||
expect_results(
|
||||
"foo",
|
||||
&[
|
||||
directory("~/dev/foobar"),
|
||||
directory("~/src/myfoo"),
|
||||
directory("~/foo"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"directory:~/foo",
|
||||
78,
|
||||
Some(MatchFieldName::Path),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"directory:~/dev/foobar",
|
||||
74,
|
||||
Some(MatchFieldName::Path),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"directory:~/src/myfoo",
|
||||
56,
|
||||
Some(MatchFieldName::Path),
|
||||
&[8, 9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_no_match_returns_empty() {
|
||||
expect_results(
|
||||
"zzz",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f5_highest_scoring_embedding_beats_greedy() {
|
||||
expect_results(
|
||||
"mtu",
|
||||
&[
|
||||
session("maestro-tui", None, None, None),
|
||||
session("maestro-daemon", None, None, None),
|
||||
session("mtui-scratch", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:mtui-scratch",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:maestro-tui",
|
||||
91,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 8, 9],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f6_name_beats_weighted_directory() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("api", Some("~/dev/web"), Some("bash"), None),
|
||||
session("worker", Some("~/dev/api"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
|
||||
(
|
||||
"session:worker",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f7_case_insensitive_length_breaks_tie() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("API-Gateway", None, None, None),
|
||||
session("apiary", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:apiary",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:API-Gateway",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f8_exact_tool_beats_command_field() {
|
||||
expect_results(
|
||||
"claude",
|
||||
&[
|
||||
session(
|
||||
"nameless",
|
||||
Some("~/dev/x"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
session("clay", Some("~/dev/y"), Some("bash"), None),
|
||||
],
|
||||
&[(
|
||||
"session:nameless",
|
||||
186,
|
||||
Some(MatchFieldName::Tool),
|
||||
&[0, 1, 2, 3, 4, 5],
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f9_separator_earns_word_boundary_bonus() {
|
||||
expect_results(
|
||||
"ws",
|
||||
&[
|
||||
session("websearch", None, None, None),
|
||||
session("web-search", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:web-search",
|
||||
71,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 4],
|
||||
),
|
||||
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f10_command_labels_rank_by_boundary_and_gap() {
|
||||
expect_results(
|
||||
"se",
|
||||
&[
|
||||
command("Kill selected session"),
|
||||
command("Create a session"),
|
||||
command("Show help"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"command:Show help",
|
||||
53,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 6],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
51,
|
||||
Some(MatchFieldName::Label),
|
||||
&[5, 6],
|
||||
),
|
||||
(
|
||||
"command:Create a session",
|
||||
47,
|
||||
Some(MatchFieldName::Label),
|
||||
&[9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f11_directory_matches_separate_on_initial_gap() {
|
||||
expect_results(
|
||||
"dev",
|
||||
&[
|
||||
session("api", Some("~/dev/api"), Some("bash"), None),
|
||||
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
|
||||
session("scratch", Some("~/srv/devops"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:api",
|
||||
54,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"session:scratch",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"session:notes",
|
||||
44,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[12, 13, 14],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f12_exact_beats_prefix() {
|
||||
expect_results(
|
||||
"quit",
|
||||
&[command("Quit Maestro"), command("Quit")],
|
||||
&[
|
||||
(
|
||||
"command:Quit",
|
||||
178,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Quit Maestro",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -813,6 +813,21 @@ impl Palette {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background for the card whose session the pane is showing
|
||||
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
|
||||
/// selection surface, so the sidebar reads as one ladder: unfilled, half
|
||||
/// filled for the session in the pane, fully filled under the cursor.
|
||||
///
|
||||
/// Blended rather than added as a third `Surface` — a new surface means
|
||||
/// eight more colours hand-picked seventeen times — which is also why it
|
||||
/// borrows the *selection* surface's foregrounds: those are the ones
|
||||
/// already picked for a filled row. A preset whose colours the terminal
|
||||
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
|
||||
/// says "in the pane" with the filled glyph alone.
|
||||
pub fn pane_row_bg(&self) -> Color {
|
||||
mix(self.card.bg, self.selection.bg, 0.55)
|
||||
}
|
||||
|
||||
/// Border style for a panel, by whether it holds focus.
|
||||
pub fn border_style(&self, focused: bool) -> Style {
|
||||
if focused {
|
||||
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
|
||||
*active_slot().read().expect("active palette lock poisoned")
|
||||
}
|
||||
|
||||
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
|
||||
/// there is nothing to blend and `from` comes back unchanged.
|
||||
fn mix(from: Color, to: Color, amount: f64) -> Color {
|
||||
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
|
||||
return from;
|
||||
};
|
||||
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
|
||||
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
|
||||
}
|
||||
|
||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||
/// luminance — they are whatever the terminal theme says — so they return
|
||||
/// `None` and cannot be contrast-checked.
|
||||
@@ -937,6 +962,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane row is the middle rung: a visible fill against the card, still
|
||||
/// a different surface from the cursor's, and carrying the selection
|
||||
/// surface's foregrounds, which it borrows rather than shipping its own.
|
||||
#[test]
|
||||
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
|
||||
for (preset, p) in rgb_presets() {
|
||||
let bg = p.pane_row_bg();
|
||||
let from_card = contrast_ratio(p.card.bg, bg)
|
||||
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
|
||||
assert!(
|
||||
from_card >= 1.15,
|
||||
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
|
||||
);
|
||||
let from_selection =
|
||||
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
|
||||
assert!(
|
||||
from_selection >= 1.15,
|
||||
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
|
||||
);
|
||||
for (role, color) in p.selection.roles() {
|
||||
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
|
||||
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset the terminal themes has nothing to blend from.
|
||||
#[test]
|
||||
fn ansi_keeps_the_card_background_for_the_pane_row() {
|
||||
let p = Palette::ANSI;
|
||||
assert_eq!(p.pane_row_bg(), p.card.bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_supplies_every_role() {
|
||||
for (name, p) in Palette::presets() {
|
||||
|
||||
@@ -415,6 +415,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let state = AppState::new();
|
||||
@@ -511,6 +512,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
@@ -561,6 +563,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let draw_map = |focus| {
|
||||
@@ -601,6 +604,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let render = |focus| {
|
||||
|
||||
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
|
||||
/// both card rows, replacing the old inline dot glyph on the meta line.
|
||||
const RAIL: &str = "▌";
|
||||
|
||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
|
||||
/// on its own row: a highlight that lands on the session already in the pane
|
||||
/// still has to be visible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CardFill {
|
||||
/// An ordinary card, on the card surface.
|
||||
None,
|
||||
/// The session the pane is showing.
|
||||
Pane,
|
||||
/// Under the cursor, with the sidebar holding the keyboard.
|
||||
Cursor,
|
||||
}
|
||||
|
||||
impl CardFill {
|
||||
/// Both filled tiers carry the selection surface's foregrounds — those are
|
||||
/// the colours picked for a filled row, and a colour chosen for the card
|
||||
/// background is not legible on one.
|
||||
fn filled(self) -> bool {
|
||||
self != CardFill::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Cards take the cursor's fill only while the sidebar owns focus. An
|
||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||
/// keyboard via a filled selection background.
|
||||
fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||
fn card_base(fill: CardFill, focused: bool) -> Style {
|
||||
let palette = palette::active();
|
||||
let mut style = palette.surface(highlighted && focused).text_style();
|
||||
if focused && !highlighted {
|
||||
style = style.bg(palette.sidebar_focus_bg);
|
||||
let mut style = palette.surface(fill.filled()).text_style();
|
||||
match fill {
|
||||
CardFill::Cursor => {}
|
||||
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
|
||||
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
|
||||
CardFill::None => {}
|
||||
}
|
||||
style
|
||||
}
|
||||
@@ -55,6 +80,9 @@ pub struct Sidebar {
|
||||
pub sessions: Vec<Session>,
|
||||
pub highlight: Option<SidebarItem>,
|
||||
pub attached: HashSet<Uuid>,
|
||||
/// The session the active tab's pane is showing. It wears a fill of its
|
||||
/// own, so "in the pane" is not carried by the ● glyph alone.
|
||||
pub pane_session: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -113,8 +141,7 @@ impl Sidebar {
|
||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||
if !group.collapsed {
|
||||
for s in &group.sessions {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let base = card_base(self.card_fill(s, focused), focused);
|
||||
lines.push(Line::from(self.name_row(s, width, focused)));
|
||||
let meta = self.meta_row(s, width, focused);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
@@ -226,6 +253,18 @@ impl Sidebar {
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
/// Which fill this session's card wears. The cursor wins on its own row,
|
||||
/// so a highlight parked on the session already in the pane stays visible.
|
||||
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
|
||||
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
|
||||
CardFill::Cursor
|
||||
} else if self.pane_session == Some(s.id) {
|
||||
CardFill::Pane
|
||||
} else {
|
||||
CardFill::None
|
||||
}
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
@@ -233,12 +272,13 @@ impl Sidebar {
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
|
||||
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
@@ -257,7 +297,7 @@ impl Sidebar {
|
||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||
base.patch(
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
)
|
||||
@@ -283,11 +323,11 @@ impl Sidebar {
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let rail_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
let edge = highlighted && !focused;
|
||||
@@ -308,7 +348,7 @@ impl Sidebar {
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(highlighted && focused);
|
||||
let surface = palette.surface(fill.filled());
|
||||
for (kind, text) in b.stats() {
|
||||
let style = match kind {
|
||||
StatKind::Added => muted.fg(surface.active),
|
||||
@@ -352,10 +392,8 @@ impl Sidebar {
|
||||
/// state word is never spelled out — this colour carries it. The highlighted
|
||||
/// card takes its state colours from the selection surface: a colour picked
|
||||
/// for the dark card background is not legible on the selection background.
|
||||
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.activity(state)
|
||||
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
|
||||
palette::active().surface(fill.filled()).activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -521,6 +559,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||
assert!(
|
||||
@@ -545,6 +584,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
})
|
||||
.groups(&HashSet::new());
|
||||
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||
@@ -556,6 +596,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||
assert!(sidebar.rendered_items().is_empty());
|
||||
@@ -567,6 +608,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let key = PathBuf::from("/tmp");
|
||||
let mut collapsed = HashSet::new();
|
||||
@@ -591,6 +633,7 @@ mod tests {
|
||||
sessions: vec![session],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
||||
let collapsed = HashSet::from([key]);
|
||||
@@ -621,6 +664,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sidebar, 40, 8));
|
||||
assert!(all[1].contains("/tmp"));
|
||||
@@ -636,6 +680,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 6));
|
||||
let line = row_containing(&all, "claude");
|
||||
@@ -646,6 +691,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether any cell in the buffer is painted on `bg`.
|
||||
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
|
||||
(0..buf.area.height)
|
||||
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
|
||||
/// not the ● glyph alone carrying "this is the one on screen".
|
||||
#[test]
|
||||
fn the_session_in_the_pane_wears_the_pane_fill() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
|
||||
|
||||
let none = Sidebar {
|
||||
pane_session: None,
|
||||
..sb
|
||||
};
|
||||
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
|
||||
}
|
||||
|
||||
/// The cursor's fill wins on its own row: a highlight that lands on the
|
||||
/// session already in the pane has to stay visible.
|
||||
#[test]
|
||||
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(sid)),
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 6, true);
|
||||
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
|
||||
assert!(
|
||||
!any_cell_on(&buf, p.pane_row_bg()),
|
||||
"the pane fill is showing through the cursor's own row"
|
||||
);
|
||||
}
|
||||
|
||||
/// A card the pane is showing is a filled row, so it takes the selection
|
||||
/// surface's foregrounds — a colour picked for the card background is not
|
||||
/// legible on a fill.
|
||||
#[test]
|
||||
fn the_pane_row_carries_selection_foregrounds() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let mut sess = session_named(1, "claude");
|
||||
sess.activity = ActivityState::AwaitingInput;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![sess],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let on_pane_row = |fg: Color| {
|
||||
(0..buf.area.height).any(|y| {
|
||||
(0..buf.area.width).any(|x| {
|
||||
let st = buf[(x, y)].style();
|
||||
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
|
||||
"the rail must carry the state in the filled surface's colours"
|
||||
);
|
||||
assert!(
|
||||
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
|
||||
"a card surface colour is painted onto the pane fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_session_uses_cross_glyph() {
|
||||
let mut sess = session_named(1, "claude");
|
||||
@@ -654,6 +780,7 @@ mod tests {
|
||||
sessions: vec![sess],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let all = rows(&buf);
|
||||
@@ -682,6 +809,7 @@ mod tests {
|
||||
sessions: vec![blocked, clear, unprobed],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 44, 10));
|
||||
|
||||
@@ -728,6 +856,7 @@ mod tests {
|
||||
sessions: vec![generated, settled],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
@@ -760,6 +889,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
@@ -786,6 +916,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
@@ -817,6 +948,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -856,6 +988,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let found = (0..buf.area.height)
|
||||
@@ -884,6 +1017,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let on_selection = |fg: Color| {
|
||||
@@ -923,6 +1057,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -942,6 +1077,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 60, 6));
|
||||
let meta = content(&all[3]);
|
||||
@@ -970,6 +1106,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -999,6 +1136,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1026,6 +1164,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1054,6 +1193,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 42, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1087,6 +1227,7 @@ mod tests {
|
||||
sessions: vec![legacy],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 40, 8));
|
||||
assert!(
|
||||
@@ -1130,6 +1271,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||
assert!(
|
||||
@@ -1145,6 +1287,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
@@ -1166,6 +1309,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 9, 6));
|
||||
assert!(
|
||||
@@ -1192,6 +1336,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
@@ -1216,6 +1361,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
@@ -1237,6 +1383,7 @@ mod tests {
|
||||
sessions: vec![highlighted],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
for y in 0..buf.area.height {
|
||||
@@ -1262,6 +1409,7 @@ mod tests {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let all = rows(&buf);
|
||||
@@ -1297,6 +1445,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||
let y = (0..buf.area.height)
|
||||
@@ -1316,6 +1465,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
},
|
||||
28,
|
||||
8,
|
||||
@@ -1336,6 +1486,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||
@@ -1354,6 +1505,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = content(row_containing(&all, "claude"));
|
||||
@@ -1371,6 +1523,7 @@ mod tests {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "bash");
|
||||
@@ -1390,6 +1543,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "an-unreasonably");
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge: the only ring left in the session list
|
||||
is `.is-active`'s, which means "open in a pane". The dashed edges belong to the
|
||||
pane — the canvas ring and the throwaway tab.
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
|
||||
belong to the pane: the canvas ring and the throwaway tab.
|
||||
|
||||
The hue is a palette token, not a fixed colour: a palette whose accent already
|
||||
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
|
||||
|
||||
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
|
||||
selection background. Contrast is a property of the palette (values are RGB)
|
||||
and is asserted in the palette's tests, not left to the terminal theme.
|
||||
|
||||
## Which row is filled
|
||||
|
||||
Both clients say every one of these with a **background fill**, never an edge.
|
||||
A row is usually several of them at once, so they form a ladder: the tier
|
||||
furthest down wins the paint.
|
||||
|
||||
| Tier | Web | TUI |
|
||||
|---|---|---|
|
||||
| Alive | 6% accent wash | — (the rail carries it) |
|
||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
||||
|
||||
The web ladder is written as one ordered run of equal-specificity rules in
|
||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||
*is* the precedence. Add a tier where it belongs in that run, never by piling
|
||||
on another class to out-weigh the rule above it.
|
||||
|
||||
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
|
||||
since those are the colours picked for a filled row. `pane_row_bg()` is
|
||||
blended from the two surfaces rather than shipped as a third one — a third
|
||||
surface would mean eight more colours hand-picked seventeen times — and the
|
||||
palette tests assert it stays a visible step from both. The cursor's fill wins
|
||||
on its own row, so a highlight parked on the session already in the pane is
|
||||
still visible.
|
||||
|
||||
The one edge left in either list is the TUI's `▏` marker, which stands in for
|
||||
the cursor's fill while the pane holds the keyboard, and the web's ring on a
|
||||
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||
step up to.
|
||||
|
||||
The duration text inherits its state's colour; the exited and command
|
||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||
|
||||
+31
-40
@@ -101,9 +101,11 @@
|
||||
oklch(0.99 0.005 var(--preview-h)),
|
||||
oklch(0.19 0.045 var(--preview-h))
|
||||
);
|
||||
/* Opaque, not a wash: the previewed row reads as a fill, and a fill has to
|
||||
land on the same colour whatever tier the row was already painted. */
|
||||
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
|
||||
the same colour whatever tier the row was already painted. `--accent-fill`
|
||||
is the same recipe on the accent, so the two tiers differ by hue alone. */
|
||||
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
|
||||
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
|
||||
--preview-ring: light-dark(
|
||||
oklch(0.55 0.16 var(--preview-h) / 0.5),
|
||||
oklch(0.78 0.14 var(--preview-h) / 0.6)
|
||||
@@ -1148,38 +1150,35 @@ textarea {
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||
.tree-leaf[data-status="running"] {
|
||||
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
|
||||
once, so every qualifier below sits in `:where()`: each rule is worth the
|
||||
same specificity and the ladder is decided purely by the order they appear
|
||||
in, latest wins. Add a tier where it belongs in that order, not by piling on
|
||||
another class to out-weigh the rule above it. Each tier paints a background
|
||||
and nothing else — the list says all of this with fill, never an edge. */
|
||||
|
||||
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
|
||||
.tree-leaf:where([data-status="running"]) {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the
|
||||
preview hue — never the accent, which belongs to the row actually open. */
|
||||
.tree-leaf:hover {
|
||||
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
|
||||
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
|
||||
which belongs to the row actually open. */
|
||||
.tree-leaf:where(:hover, .is-highlighted) {
|
||||
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf[data-status="running"]:hover {
|
||||
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
|
||||
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf:focus-visible {
|
||||
.tree-leaf:where(:focus-visible) {
|
||||
outline: none;
|
||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* Where the keyboard walk (j/k) is standing. On a session row the cursor is
|
||||
also what previews it, so it reads exactly like a hover — the preview hue,
|
||||
deepening to the full fill once the preview lands. A folder row previews
|
||||
nothing, so it keeps the plain cursor ring. */
|
||||
.tree-leaf.is-highlighted {
|
||||
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf.is-highlighted[data-status="running"] {
|
||||
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* A folder row previews nothing, so its cursor stays a ring. */
|
||||
.tree-folder-header.is-highlighted {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
||||
}
|
||||
@@ -1188,25 +1187,24 @@ textarea {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The session currently mounted in the content pane. */
|
||||
.tree-leaf.is-active,
|
||||
.tree-leaf.is-active[data-status="running"] {
|
||||
background: var(--accent-tint);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||
/* 3. Open in some pane, but not the focused one (issue #33). */
|
||||
.tree-leaf:where(.is-open) {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* 4. The session currently mounted in the content pane. */
|
||||
.tree-leaf:where(.is-active) {
|
||||
background: var(--accent-fill);
|
||||
}
|
||||
|
||||
.tree-leaf.is-active .leaf-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The row the content pane is previewing right now — after `.is-active` so a
|
||||
preview of an already-open session still reads as provisional. */
|
||||
.tree-leaf.is-previewing,
|
||||
.tree-leaf.is-previewing[data-status="running"],
|
||||
.tree-leaf.is-previewing.is-active,
|
||||
.tree-leaf.is-previewing.is-highlighted {
|
||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||
session that is already open still reads as provisional. */
|
||||
.tree-leaf:where(.is-previewing) {
|
||||
background: var(--preview-tint);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||
@@ -3300,13 +3298,6 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
/* sidebar two-tier highlight (issue #33).
|
||||
.is-active = focused pane (loud, from existing rule); .is-open = open
|
||||
elsewhere (quiet). */
|
||||
.tree-leaf.is-open {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||
|
||||
Reference in New Issue
Block a user