Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7d5fb505 | ||
|
|
66f386b87b | ||
|
|
e18ad716a0 | ||
|
|
f7e7a77912 | ||
|
|
d4c72577b9 | ||
|
|
aed9a5eec0 | ||
|
|
f84199d292 | ||
|
|
64d5829037 | ||
|
|
2ad2858fd5 | ||
|
|
fa2a26faa9 | ||
|
|
b5feb3c39a | ||
|
|
e12baa23c6 | ||
|
|
63191c7be1 | ||
|
|
acb84b91ef | ||
|
|
c8ab4a531e | ||
|
|
21fc20b30a | ||
|
|
abbd15a8b2 | ||
|
|
1d18e21420 | ||
|
|
38a65e585f | ||
|
|
adbf57fc49 | ||
|
|
7935663ed7 | ||
|
|
bff0eddc12 | ||
|
|
3ce1a6f331 | ||
|
|
8cb6aeb61e | ||
|
|
247060de68 |
@@ -0,0 +1,20 @@
|
||||
# The integration suites drive real tmux servers and real git repos, and most
|
||||
# of their wall time is spent waiting on those subprocesses rather than on the
|
||||
# CPU. nextest runs test binaries in parallel — which plain `cargo test` does
|
||||
# not — but at its `num-cpus` default the tmux-backed tests starve each other
|
||||
# and the tighter `wait_for_async` deadlines (5s) start timing out. Capping the
|
||||
# concurrency keeps the cross-binary win without the contention.
|
||||
|
||||
[profile.ci]
|
||||
test-threads = 4
|
||||
|
||||
# Anything past this is reported as slow while it keeps running, so a genuine
|
||||
# hang is visible in the log instead of only showing up as a job timeout.
|
||||
slow-timeout = { period = "30s", terminate-after = 8 }
|
||||
|
||||
failure-output = "immediate-final"
|
||||
final-status-level = "slow"
|
||||
|
||||
# nextest does not run doctests. There are none today (every ``` fence in the
|
||||
# crates is `toml` or `text`), so nothing is lost; adding a real doctest means
|
||||
# adding `cargo test --doc` to CI alongside this.
|
||||
+102
-21
@@ -11,6 +11,10 @@ concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -30,23 +34,36 @@ jobs:
|
||||
--component rustfmt --component clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Node.js + pnpm
|
||||
# `crates/maestro-web/build.rs` shells out to pnpm whenever
|
||||
# MAESTRO_WEB_DIST is unset, so the Rust build needs node here too.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y --no-install-recommends nodejs
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Caching all of `target/` under a prefix restore-key let every stale
|
||||
# artifact a previous run dragged in survive forever, so the entry grew
|
||||
# without bound and cost ~60s to move. rust-cache keys on the rustc
|
||||
# version plus the lockfile and prunes target/ to what that lockfile
|
||||
# actually needs. Only main writes the cache; PRs restore from it and
|
||||
# skip the save, which also keeps concurrent PRs from thrashing it.
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
# nextest runs test *binaries* in parallel, which plain `cargo test`
|
||||
# does not — this suite is 20 integration binaries that mostly sit
|
||||
# waiting on tmux and git. Thread count is capped in
|
||||
# `.config/nextest.toml`; see the comment there.
|
||||
- name: Install cargo-nextest
|
||||
run: |
|
||||
curl -sSfL https://get.nexte.st/latest/linux \
|
||||
| tar zxf - -C "$HOME/.cargo/bin"
|
||||
|
||||
- name: fmt
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -55,7 +72,7 @@ jobs:
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: test
|
||||
run: cargo test --all
|
||||
run: cargo nextest run --all --profile ci
|
||||
|
||||
# NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon /
|
||||
# tui). Verify each subset compiles and its tests pass on its own, and
|
||||
@@ -66,7 +83,8 @@ jobs:
|
||||
for f in cli daemon tui; do
|
||||
echo "::group::maestro --no-default-features --features $f"
|
||||
cargo build -p maestro --no-default-features --features "$f"
|
||||
cargo test -p maestro --no-default-features --features "$f"
|
||||
cargo nextest run -p maestro --no-default-features --features "$f" \
|
||||
--profile ci
|
||||
echo "::endgroup::"
|
||||
done
|
||||
echo "::group::maestro with no features must fail to compile"
|
||||
@@ -81,12 +99,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js + pnpm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends curl ca-certificates
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y --no-install-recommends nodejs
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
@@ -110,25 +128,88 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# A cold /nix means every run recompiles the entire release closure —
|
||||
# every third-party crate under `lto = "thin"` + `codegen-units = 1`,
|
||||
# which was ~8.5 minutes and the whole pipeline's critical path. With the
|
||||
# store restored, crane's deps-only derivation is already built and only
|
||||
# maestro's own crates recompile.
|
||||
#
|
||||
# The key is the lockfiles that closure derives from, not the sources: a
|
||||
# source-only push is an exact hit, which makes actions/cache skip the
|
||||
# (multi-GB) save. Only the cheap final crates rebuild in that case.
|
||||
- name: Restore nix store
|
||||
id: nix-cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: /nix
|
||||
key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }}
|
||||
restore-keys: |
|
||||
nix-${{ runner.os }}-
|
||||
|
||||
# The runner container is root with no `nixbld` group, but the installer
|
||||
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
||||
# config goes in first. Flakes are off by default and `nix build .#…`
|
||||
# needs them. Written again afterwards in case the installer replaced it.
|
||||
#
|
||||
# A restored /nix already holds nix itself — the single-user installer
|
||||
# puts its profile under /nix/var/nix/profiles/per-user/root, which is
|
||||
# inside the cached path. Only $HOME (the ~/.nix-profile symlink, the
|
||||
# PATH entry, NIX_SSL_CERT_FILE) lives outside it and has to be redone.
|
||||
- name: Install nix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /etc/nix
|
||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||
profile=/nix/var/nix/profiles/per-user/root/profile
|
||||
if [ -x "$profile/bin/nix" ]; then
|
||||
ln -sfn "$profile" "$HOME/.nix-profile"
|
||||
else
|
||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||
fi
|
||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
# Sourcing the installer's profile script is what normally sets this;
|
||||
# without it a restored store cannot talk to cache.nixos.org.
|
||||
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /etc/ssl/certs/ca-certificates.crt ]; then
|
||||
echo "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
# Fail here rather than 8 minutes into a build if a restored store is
|
||||
# unusable.
|
||||
- name: Check nix works
|
||||
run: nix --extra-experimental-features "nix-command flakes" --version
|
||||
|
||||
# The installer does not enable flakes, so `nix build .#…` needs the
|
||||
# features passed explicitly — a local machine that has them in
|
||||
# nix.conf will build fine while CI fails without this.
|
||||
- name: build flake packages
|
||||
#
|
||||
# This is the step that guards pnpmDeps.hash, and it is cheap, so it runs
|
||||
# on every event.
|
||||
- name: build SPA package
|
||||
run: |
|
||||
nix --extra-experimental-features "nix-command flakes" build \
|
||||
.#webFrontend \
|
||||
--no-link
|
||||
|
||||
# The release binaries are a full LTO compile of the workspace. The rust
|
||||
# job already builds, lints and tests the same sources, so the only extra
|
||||
# signal here is that the *packaging* works — worth a few minutes on main,
|
||||
# not on every PR push. Trade-off: a break that is specific to the Nix
|
||||
# packaging surfaces on main instead of on the PR.
|
||||
- name: build release packages
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
nix --extra-experimental-features "nix-command flakes" build \
|
||||
.#maestro \
|
||||
.#maestro-web \
|
||||
--no-link
|
||||
|
||||
# Saved only from a push, so a PR that built webFrontend alone can never
|
||||
# write a store missing the Rust closure and have a later run restore it
|
||||
# through the prefix key.
|
||||
- name: Save nix store
|
||||
if: github.event_name == 'push' && steps.nix-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: /nix
|
||||
key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }}
|
||||
|
||||
@@ -174,10 +174,20 @@ pub struct TuiSection {
|
||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TuiTheme {
|
||||
/// The terminal-native dark palette and the default.
|
||||
/// The graphite dark palette and the default.
|
||||
Dark,
|
||||
/// A high-contrast light palette.
|
||||
Light,
|
||||
/// Ice Cyan — cyan accent on the graphite base.
|
||||
IceCyan,
|
||||
/// Periwinkle — indigo base, blue-violet accent.
|
||||
Periwinkle,
|
||||
/// Amber Phosphor — CRT amber on warm graphite.
|
||||
AmberPhosphor,
|
||||
/// Ember — warm orange-red accent.
|
||||
Ember,
|
||||
/// Orchid — muted violet accent.
|
||||
Orchid,
|
||||
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||
Ansi,
|
||||
/// Solarized Dark.
|
||||
@@ -204,6 +214,11 @@ pub enum TuiTheme {
|
||||
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
|
||||
("dark", TuiTheme::Dark),
|
||||
("light", TuiTheme::Light),
|
||||
("ice-cyan", TuiTheme::IceCyan),
|
||||
("periwinkle", TuiTheme::Periwinkle),
|
||||
("amber-phosphor", TuiTheme::AmberPhosphor),
|
||||
("ember", TuiTheme::Ember),
|
||||
("orchid", TuiTheme::Orchid),
|
||||
("ansi", TuiTheme::Ansi),
|
||||
("solarized-dark", TuiTheme::SolarizedDark),
|
||||
("solarized-light", TuiTheme::SolarizedLight),
|
||||
|
||||
@@ -66,6 +66,44 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a session's pane must be continuously focused (a client's pane,
|
||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||
/// operator has looked, so the rail stops asking for attention.
|
||||
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
|
||||
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
||||
///
|
||||
/// `suppressed` is the session's sticky acknowledgement, threaded in and
|
||||
/// mutated in place: once set, it holds even after the pane loses focus, so
|
||||
/// the state stays `Idle` until the session does new work. Only a fresh
|
||||
/// `Active` — the session actually running something again — resets it, so
|
||||
/// the *next* time inference lands on `AwaitingInput` it glows again. Losing
|
||||
/// and regaining focus alone never re-arms it.
|
||||
pub fn apply_focus_suppression(
|
||||
raw: ActivityState,
|
||||
focused_since: Option<std::time::Instant>,
|
||||
now: std::time::Instant,
|
||||
suppressed: &mut bool,
|
||||
) -> ActivityState {
|
||||
if raw == ActivityState::Active {
|
||||
*suppressed = false;
|
||||
}
|
||||
if raw != ActivityState::AwaitingInput {
|
||||
return raw;
|
||||
}
|
||||
if let Some(since) = focused_since {
|
||||
if now.saturating_duration_since(since) >= FOCUS_CLEAR_HOLD {
|
||||
*suppressed = true;
|
||||
}
|
||||
}
|
||||
if *suppressed {
|
||||
ActivityState::Idle
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -118,4 +156,99 @@ mod tests {
|
||||
Some("short")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaiting_input_glows_until_focus_hold_is_reached() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = false;
|
||||
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_secs(1));
|
||||
assert_eq!(
|
||||
apply_focus_suppression(
|
||||
ActivityState::AwaitingInput,
|
||||
Some(since),
|
||||
now,
|
||||
&mut suppressed
|
||||
),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaiting_input_clears_once_focus_hold_is_reached() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = false;
|
||||
let since = now - FOCUS_CLEAR_HOLD;
|
||||
assert_eq!(
|
||||
apply_focus_suppression(
|
||||
ActivityState::AwaitingInput,
|
||||
Some(since),
|
||||
now,
|
||||
&mut suppressed
|
||||
),
|
||||
ActivityState::Idle
|
||||
);
|
||||
assert!(suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfocused_awaiting_input_never_clears() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = false;
|
||||
assert_eq!(
|
||||
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppression_survives_losing_focus() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = true;
|
||||
// Focus dropped (`None`) but the session hasn't done new work.
|
||||
assert_eq!(
|
||||
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
|
||||
ActivityState::Idle
|
||||
);
|
||||
assert!(suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_active_cycle_clears_the_suppression() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = true;
|
||||
assert_eq!(
|
||||
apply_focus_suppression(ActivityState::Active, None, now, &mut suppressed),
|
||||
ActivityState::Active
|
||||
);
|
||||
assert!(!suppressed);
|
||||
|
||||
// Blocked again, freshly focused: glows until the hold is reached again.
|
||||
assert_eq!(
|
||||
apply_focus_suppression(
|
||||
ActivityState::AwaitingInput,
|
||||
Some(now),
|
||||
now,
|
||||
&mut suppressed
|
||||
),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_error_pass_through_untouched() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut suppressed = false;
|
||||
assert_eq!(
|
||||
apply_focus_suppression(ActivityState::Idle, Some(now), now, &mut suppressed),
|
||||
ActivityState::Idle
|
||||
);
|
||||
assert_eq!(
|
||||
apply_focus_suppression(ActivityState::Error, Some(now), now, &mut suppressed),
|
||||
ActivityState::Error
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ use axum::{Json, Router};
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
|
||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -45,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(routes::patterns::SESSION_FOCUS, post(set_focus))
|
||||
.route(
|
||||
routes::patterns::SESSION,
|
||||
delete(kill_session).patch(rename_session),
|
||||
@@ -275,6 +277,28 @@ async fn send_input(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/focus`: a client reports whether its pane is the
|
||||
/// one currently showing this session — read on the next activity tick to
|
||||
/// decide whether an `AwaitingInput` glow has been looked at long enough to
|
||||
/// clear (docs/SIDEBAR.md). Unknown session is 404. Answers `204 No Content`.
|
||||
async fn set_focus(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<SetFocusRequest>,
|
||||
) -> Response {
|
||||
match s.manager.set_focused(id, body.focused).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
session_error_status(&info.code),
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(status, Json(ApiError::new(code, message))).into_response()
|
||||
}
|
||||
|
||||
@@ -71,6 +71,15 @@ pub struct SessionRecord {
|
||||
/// (#115), so this is the observable that says its outcomes are safe to
|
||||
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||
pub settled: bool,
|
||||
/// When a client's pane last became the one showing this session
|
||||
/// (`POST /.../focus`), `None` while unfocused. Daemon-local; never rides
|
||||
/// the wire.
|
||||
pub focused_since: Option<std::time::Instant>,
|
||||
/// Sticky acknowledgement applied by [`crate::activity::apply_focus_suppression`]:
|
||||
/// once the pane has been focused long enough to clear an `AwaitingInput`
|
||||
/// glow, this holds through focus loss until the session does new work.
|
||||
/// Daemon-local; never rides the wire.
|
||||
pub awaiting_suppressed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -456,6 +465,8 @@ impl SessionManager {
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -659,6 +670,8 @@ impl SessionManager {
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -799,6 +812,17 @@ impl SessionManager {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record whether a client's pane is currently showing `id`. Read by the
|
||||
/// activity pass's next tick to decide whether an `AwaitingInput` glow
|
||||
/// has been looked at long enough to clear (`apply_focus_suppression`).
|
||||
/// An unknown session is [`SessionError::NotFound`].
|
||||
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||
rec.focused_since = focused.then(std::time::Instant::now);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
|
||||
@@ -955,10 +979,19 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
// #105: while a report is in force it supplies the state and
|
||||
// nothing here may overwrite it; only an unreported session is
|
||||
// decided by inference.
|
||||
let activity = match &rec.reported_activity {
|
||||
let raw_activity = match &rec.reported_activity {
|
||||
Some(report) => report.state,
|
||||
None => crate::activity::decide(idle, awaiting),
|
||||
};
|
||||
// A pane looked at long enough clears an `AwaitingInput` glow
|
||||
// (docs/SIDEBAR.md); the acknowledgement is sticky until the
|
||||
// session does new work again.
|
||||
let activity = crate::activity::apply_focus_suppression(
|
||||
raw_activity,
|
||||
rec.focused_since,
|
||||
std::time::Instant::now(),
|
||||
&mut rec.awaiting_suppressed,
|
||||
);
|
||||
let foreground = Some(pane.command.clone());
|
||||
// The pane's live cwd, drives the dir-based session tree; empty means
|
||||
// tmux reported none.
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||
SetFocusRequest,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -166,6 +167,18 @@ impl DaemonClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/focus`: report whether this client's pane is
|
||||
/// the one currently showing `id` — not merely highlighted in a sidebar.
|
||||
/// Answers `204 No Content`.
|
||||
pub async fn set_focus(&self, id: Uuid, focused: bool) -> Result<(), ClientError> {
|
||||
let req = SetFocusRequest { focused };
|
||||
let resp = self
|
||||
.send(Method::POST, &routes::session_focus(id), &[], Some(&req))
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||
self.get(routes::INFO, &[]).await
|
||||
}
|
||||
|
||||
@@ -573,6 +573,17 @@ fn submit_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions/:id/focus` body: a client reports whether its
|
||||
/// pane is the one currently showing this session — not merely highlighted
|
||||
/// in a sidebar. The daemon keeps one focus clock per session, last write
|
||||
/// wins; a second client attached to the same session can race the first
|
||||
/// one's unfocus, which is accepted as a rare, harmless case (worst outcome
|
||||
/// is one extra tick before the glow clears).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SetFocusRequest {
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
/// Longest session name the daemon stores (#125), in characters. A name is a
|
||||
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
||||
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
||||
|
||||
@@ -54,6 +54,12 @@ pub fn session_input(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/input")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/focus`: a client reports whether its pane is the
|
||||
/// one currently showing this session.
|
||||
pub fn session_focus(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/focus")
|
||||
}
|
||||
|
||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||
pub fn attach(id: Uuid) -> String {
|
||||
format!("{API_PREFIX}/attach/{id}")
|
||||
@@ -65,6 +71,7 @@ pub mod patterns {
|
||||
pub const SESSION: &str = "/api/sessions/:id";
|
||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
|
||||
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
|
||||
pub const ATTACH: &str = "/api/attach/:id";
|
||||
}
|
||||
|
||||
@@ -180,6 +187,10 @@ mod tests {
|
||||
session_input(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
|
||||
);
|
||||
assert_eq!(
|
||||
session_focus(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
|
||||
);
|
||||
assert_eq!(
|
||||
attach(id()),
|
||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -206,6 +217,10 @@ mod tests {
|
||||
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::SESSION_FOCUS.trim_end_matches(":id/focus"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
attach(id()).trim_end_matches(&id().to_string())
|
||||
|
||||
@@ -83,6 +83,12 @@ pub struct App {
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
clicks: ClickTracker,
|
||||
/// The session, if any, last reported to the daemon as `focused` — the
|
||||
/// active tab's pane owning the keyboard, never a sidebar highlight.
|
||||
/// [`Self::sync_focus`] diffs against this every loop tick so every path
|
||||
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
||||
/// mouse) is covered without each one remembering to report it.
|
||||
focused_session: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for App {
|
||||
@@ -165,6 +171,7 @@ impl App {
|
||||
region: RegionMap::default(),
|
||||
last_sent_size: HashMap::new(),
|
||||
clicks: ClickTracker::default(),
|
||||
focused_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +185,7 @@ impl App {
|
||||
}
|
||||
let mut events = EventStream::new();
|
||||
loop {
|
||||
self.sync_focus().await;
|
||||
self.redraw(terminal)?;
|
||||
|
||||
tokio::select! {
|
||||
@@ -601,12 +609,38 @@ impl App {
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Put the spawn form up with a fresh draft behind it.
|
||||
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||
/// project of the session the operator was last on — so `n` then Enter
|
||||
/// spawns another session there, the way "spawn similar" would. A
|
||||
/// worktree session seeds its *base repository*, not its checkout: the
|
||||
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
||||
/// rule as web/src/spawnSimilar.ts.
|
||||
fn open_spawn_modal(&mut self) {
|
||||
self.draft = Some(DraftPane::default());
|
||||
let mut draft = DraftPane::default();
|
||||
draft.seed_cwd = self.focused_seed_cwd();
|
||||
self.draft = Some(draft);
|
||||
self.state.open_spawn_modal();
|
||||
}
|
||||
|
||||
/// The project of the session the operator was last on — its base
|
||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||
/// both should land on "where I currently am", not just "whatever's
|
||||
/// freshest".
|
||||
fn focused_seed_cwd(&self) -> Option<PathBuf> {
|
||||
self.state.active_session().and_then(|sid| {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.map(|s| match &s.worktree {
|
||||
Some(wt) => wt.base_repo.clone(),
|
||||
None => s.cwd.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the form and drop its half-filled state — reopening starts clean,
|
||||
/// as the web modal does.
|
||||
fn dismiss_spawn_modal(&mut self) {
|
||||
@@ -749,9 +783,11 @@ impl App {
|
||||
info,
|
||||
projects,
|
||||
names,
|
||||
focused_cwd,
|
||||
} = update
|
||||
{
|
||||
self.fast_forward_spawn(info, projects, names).await;
|
||||
self.fast_forward_spawn(info, projects, names, focused_cwd)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let want = match &update {
|
||||
@@ -818,6 +854,7 @@ impl App {
|
||||
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
||||
/// freeze keys, frames and attached panes until it returns.
|
||||
fn spawn_fast_forward(&self) {
|
||||
let focused_cwd = self.focused_seed_cwd();
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||
@@ -826,6 +863,7 @@ impl App {
|
||||
info: info.ok(),
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
focused_cwd,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -835,6 +873,7 @@ impl App {
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
) {
|
||||
// Keep the home dir: without it the pre-filled draft renders
|
||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||
@@ -848,8 +887,13 @@ impl App {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let discovered = projects.unwrap_or_default();
|
||||
let resolved =
|
||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||
let resolved = crate::fast_forward::resolve(
|
||||
&discovered,
|
||||
&presets,
|
||||
names.first().cloned(),
|
||||
Utc::now(),
|
||||
focused_cwd.as_deref(),
|
||||
);
|
||||
if let Some(req) = resolved.build_request() {
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
@@ -860,6 +904,9 @@ impl App {
|
||||
return;
|
||||
};
|
||||
draft.home_dir = home_dir;
|
||||
// The fast path resolved its own project; its pick wins over the
|
||||
// focused session's, so the seed never lands on top of it.
|
||||
draft.seed_cwd = None;
|
||||
if !presets.is_empty() {
|
||||
draft.set_presets(presets);
|
||||
}
|
||||
@@ -914,6 +961,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
|
||||
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
|
||||
/// session counts — the sidebar highlight is never a "focused pane".
|
||||
/// Best-effort: a failed report only delays the glow clearing, so
|
||||
/// nothing here surfaces to the user.
|
||||
async fn sync_focus(&mut self) {
|
||||
let wanted = matches!(self.state.focus, Focus::Pane)
|
||||
.then(|| self.state.active_session())
|
||||
.flatten();
|
||||
if wanted == self.focused_session {
|
||||
return;
|
||||
}
|
||||
if let Some(previous) = self.focused_session {
|
||||
let _ = self.daemon.set_focus(previous, false).await;
|
||||
}
|
||||
if let Some(id) = wanted {
|
||||
let _ = self.daemon.set_focus(id, true).await;
|
||||
}
|
||||
self.focused_session = wanted;
|
||||
}
|
||||
|
||||
fn redraw(
|
||||
&mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
@@ -1145,6 +1213,7 @@ enum DraftUpdate {
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1624,6 +1693,70 @@ mod tests {
|
||||
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
||||
}
|
||||
|
||||
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
|
||||
/// switching which session the pane shows, and leaving the pane back to
|
||||
/// the sidebar. A sidebar highlight alone must report nothing.
|
||||
#[tokio::test]
|
||||
async fn sync_focus_reports_pane_focus_changes_to_the_daemon() {
|
||||
use axum::extract::Path;
|
||||
use axum::routing::post;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type PostedFocus = Arc<Mutex<Vec<(Uuid, Value)>>>;
|
||||
|
||||
let posted: PostedFocus = Arc::default();
|
||||
let seen = posted.clone();
|
||||
async fn set_focus(
|
||||
axum::extract::State(seen): axum::extract::State<PostedFocus>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<Value>,
|
||||
) -> axum::http::StatusCode {
|
||||
seen.lock().unwrap().push((id, body));
|
||||
axum::http::StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new().route(
|
||||
maestro_protocol::routes::patterns::SESSION_FOCUS,
|
||||
post(set_focus).with_state(seen),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let sid_a = Uuid::from_u128(1);
|
||||
let sid_b = Uuid::from_u128(2);
|
||||
let mut app = make_app_at(&base);
|
||||
|
||||
// A sidebar highlight alone is not a focused pane.
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(sid_a));
|
||||
app.sync_focus().await;
|
||||
assert!(posted.lock().unwrap().is_empty());
|
||||
|
||||
// Attaching moves focus into the pane.
|
||||
app.state.attach_to_focused(sid_a);
|
||||
app.sync_focus().await;
|
||||
|
||||
// Switching which session the pane shows unfocuses the old one first.
|
||||
app.state.active_tab_mut().pane = Some(sid_b);
|
||||
app.sync_focus().await;
|
||||
|
||||
// Leaving the pane for the sidebar unfocuses the current session.
|
||||
app.state.focus = Focus::Sidebar;
|
||||
app.sync_focus().await;
|
||||
|
||||
assert_eq!(
|
||||
*posted.lock().unwrap(),
|
||||
vec![
|
||||
(sid_a, json!({"focused": true})),
|
||||
(sid_a, json!({"focused": false})),
|
||||
(sid_b, json!({"focused": true})),
|
||||
(sid_b, json!({"focused": false})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// No projects known yet: nothing can resolve, so the keystroke opens a
|
||||
/// spawn form instead of guessing (#67).
|
||||
#[tokio::test]
|
||||
@@ -1676,6 +1809,41 @@ mod tests {
|
||||
assert_eq!(draft.command(), "claude");
|
||||
}
|
||||
|
||||
/// Opening the form preselects the project of the session in the pane, so
|
||||
/// `n` then Enter spawns another session there.
|
||||
#[test]
|
||||
fn opening_the_spawn_modal_seeds_the_focused_sessions_project() {
|
||||
use maestro_protocol::{CatalogEvent, SessionWorktree};
|
||||
|
||||
let sid = Uuid::from_u128(21);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(sid, "/repos/maestro")],
|
||||
});
|
||||
app.state.attach_to_focused(sid);
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repos/maestro"))
|
||||
);
|
||||
|
||||
// A worktree session seeds its base repository: the daemon never
|
||||
// adopts an existing worktree (docs/WORKTREES.md).
|
||||
let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn");
|
||||
wt_session.worktree = Some(SessionWorktree {
|
||||
base_repo: "/repos/maestro".into(),
|
||||
path: "/repos/maestro/worktrees/flugelhorn".into(),
|
||||
});
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![wt_session],
|
||||
});
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repos/maestro"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A valid form flows through: pick a project (set directly — the picker
|
||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||
/// command, then submit from the Spawn row.
|
||||
@@ -1689,11 +1857,11 @@ mod tests {
|
||||
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
|
||||
app.handle_draft_input(&InputKey::Tab); // → custom command
|
||||
for c in "claude".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
}
|
||||
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
||||
app.handle_draft_input(&InputKey::Tab); // → name
|
||||
for c in "crimson-otter".chars() {
|
||||
app.handle_draft_input(&InputKey::char(c));
|
||||
|
||||
@@ -48,6 +48,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("c", "ToggleSidebarGroup"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
// Tab focuses the highlighted pane, mirroring the web keybind — bare
|
||||
// because Normal mode only exists while the sidebar is focused, so
|
||||
// nothing forwards it to a pty.
|
||||
("tab", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
||||
// thing with every default resolved and no form.
|
||||
@@ -63,14 +67,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
("ctrl-w q", "Quit"),
|
||||
// Bare quit, AoE-style: only reachable from the sidebar (Normal mode
|
||||
// never forwards keys to a pty), same reasoning as bare `tab` above.
|
||||
("q", "Quit"),
|
||||
// Mouse
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
||||
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so
|
||||
/// rebinding the leader rebinds its Insert escape automatically.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
|
||||
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
|
||||
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
|
||||
|
||||
impl TuiConfig {
|
||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||
@@ -80,6 +87,11 @@ impl TuiConfig {
|
||||
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
||||
TuiTheme::Dark => Palette::DARK,
|
||||
TuiTheme::Light => Palette::LIGHT,
|
||||
TuiTheme::IceCyan => Palette::ICE_CYAN,
|
||||
TuiTheme::Periwinkle => Palette::PERIWINKLE,
|
||||
TuiTheme::AmberPhosphor => Palette::AMBER_PHOSPHOR,
|
||||
TuiTheme::Ember => Palette::EMBER,
|
||||
TuiTheme::Orchid => Palette::ORCHID,
|
||||
TuiTheme::Ansi => Palette::ANSI,
|
||||
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
||||
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
||||
@@ -172,6 +184,11 @@ mod tests {
|
||||
for (theme, expected) in [
|
||||
(TuiTheme::Dark, Palette::DARK),
|
||||
(TuiTheme::Light, Palette::LIGHT),
|
||||
(TuiTheme::IceCyan, Palette::ICE_CYAN),
|
||||
(TuiTheme::Periwinkle, Palette::PERIWINKLE),
|
||||
(TuiTheme::AmberPhosphor, Palette::AMBER_PHOSPHOR),
|
||||
(TuiTheme::Ember, Palette::EMBER),
|
||||
(TuiTheme::Orchid, Palette::ORCHID),
|
||||
(TuiTheme::Ansi, Palette::ANSI),
|
||||
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
||||
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
||||
@@ -212,6 +229,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||
/// mode only exists while the sidebar is focused, so neither can leak
|
||||
/// into a pty; `esc` is a second way out of Insert alongside the leader.
|
||||
#[test]
|
||||
fn aoe_style_defaults_are_bound() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Normal, "q"), Some(Action::Quit));
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "tab"),
|
||||
Some(Action::AttachHighlighted),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "esc"),
|
||||
Some(Action::LeaveInsert),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_leader_is_ctrl_a_and_escapes_insert() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
|
||||
+131
-49
@@ -221,6 +221,11 @@ pub struct DraftPane {
|
||||
pub cwd_raw_visible: bool,
|
||||
pub cwd_raw: crate::text_input::TextInput,
|
||||
pub picked_cwd: Option<PathBuf>,
|
||||
/// Directory to preselect once the project scan lands — the project of
|
||||
/// the session the operator was last looking at, so `n` then Enter
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
pub seed_cwd: Option<PathBuf>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
@@ -293,6 +298,7 @@ impl Default for DraftPane {
|
||||
cwd_raw_visible: false,
|
||||
cwd_raw: crate::text_input::TextInput::default(),
|
||||
picked_cwd: None,
|
||||
seed_cwd: None,
|
||||
selected_group_key: None,
|
||||
worktree_choice: WorktreeChoice::New,
|
||||
name: crate::text_input::TextInput::default(),
|
||||
@@ -315,7 +321,9 @@ impl Default for DraftPane {
|
||||
branches_for: None,
|
||||
needs_branches: false,
|
||||
error: None,
|
||||
focused_field: DraftField::Preset,
|
||||
// The picker owns the keyboard on open: typing filters projects
|
||||
// straight away, and Tab from here reaches the command tiles.
|
||||
focused_field: DraftField::DirPicker,
|
||||
home_dir: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -358,6 +366,26 @@ impl DraftPane {
|
||||
})
|
||||
.collect();
|
||||
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
|
||||
self.apply_seed_cwd();
|
||||
}
|
||||
|
||||
/// Preselect the seeded directory now that the scan has landed: highlight
|
||||
/// its repository box and pick it, so Enter from the untouched picker
|
||||
/// spawns there. A seed no box matches is dropped — the operator picks by
|
||||
/// hand, exactly as before.
|
||||
fn apply_seed_cwd(&mut self) {
|
||||
let Some(seed) = self.seed_cwd.take() else {
|
||||
return;
|
||||
};
|
||||
let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| {
|
||||
let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed);
|
||||
matches.then(|| (i, (*g).clone()))
|
||||
});
|
||||
let Some((index, group)) = hit else {
|
||||
return;
|
||||
};
|
||||
self.highlighted = index;
|
||||
self.select_group(&group);
|
||||
}
|
||||
|
||||
/// Fill the local name batch from `GET /api/names`, seeding the field
|
||||
@@ -705,14 +733,14 @@ impl DraftPane {
|
||||
}
|
||||
|
||||
fn field_order(&self) -> Vec<DraftField> {
|
||||
let mut order = vec![DraftField::Preset];
|
||||
if self.is_custom() {
|
||||
order.push(DraftField::CustomCommand);
|
||||
}
|
||||
order.push(DraftField::DirPicker);
|
||||
let mut order = vec![DraftField::DirPicker];
|
||||
if self.cwd_raw_visible {
|
||||
order.push(DraftField::CwdRaw);
|
||||
}
|
||||
order.push(DraftField::Preset);
|
||||
if self.is_custom() {
|
||||
order.push(DraftField::CustomCommand);
|
||||
}
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
@@ -1116,15 +1144,17 @@ impl DraftPane {
|
||||
+ u16::from(self.error.is_some());
|
||||
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
|
||||
|
||||
let mut constraints = vec![Constraint::Length(1)];
|
||||
if show_custom {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1));
|
||||
// The picker leads the form: it is where focus lands on open, and Tab
|
||||
// walks down from it into the command tiles.
|
||||
let mut constraints = vec![Constraint::Length(1)]; // dir filter
|
||||
constraints.push(Constraint::Length(list_capacity as u16));
|
||||
if show_raw {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // command tiles
|
||||
if show_custom {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_wt_choice {
|
||||
@@ -1163,36 +1193,6 @@ impl DraftPane {
|
||||
// Tracks where the terminal cursor belongs for the focused text field.
|
||||
let mut cursor: Option<(u16, u16)> = None;
|
||||
|
||||
// -- command presets ---------------------------------------------------
|
||||
let tiles_row = next_row();
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
||||
for (i, preset) in self.presets.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tiles.push(Span::raw(" "));
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if i == self.selected_preset {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
||||
|
||||
// -- custom command (only while a custom tile is selected) -------------
|
||||
if show_custom {
|
||||
let row = next_row();
|
||||
draw_gutter(f, row, "");
|
||||
let col = draw_text_field(
|
||||
f,
|
||||
&self.custom_command,
|
||||
row,
|
||||
field_style(DraftField::CustomCommand),
|
||||
);
|
||||
if self.focused_field == DraftField::CustomCommand {
|
||||
cursor = Some((col, row.y));
|
||||
}
|
||||
}
|
||||
|
||||
// -- directory picker ----------------------------------------------------
|
||||
let filter_row = next_row();
|
||||
draw_gutter(f, filter_row, "dir:");
|
||||
@@ -1280,6 +1280,36 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
// -- command presets ---------------------------------------------------
|
||||
let tiles_row = next_row();
|
||||
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
|
||||
for (i, preset) in self.presets.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tiles.push(Span::raw(" "));
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if i == self.selected_preset {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
tiles.push(Span::styled(format!("[{}]", preset.label), style));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
|
||||
|
||||
// -- custom command (only while a custom tile is selected) -------------
|
||||
if show_custom {
|
||||
let row = next_row();
|
||||
draw_gutter(f, row, "");
|
||||
let col = draw_text_field(
|
||||
f,
|
||||
&self.custom_command,
|
||||
row,
|
||||
field_style(DraftField::CustomCommand),
|
||||
);
|
||||
if self.focused_field == DraftField::CustomCommand {
|
||||
cursor = Some((col, row.y));
|
||||
}
|
||||
}
|
||||
|
||||
// -- name + reroll ---------------------------------------------------------
|
||||
let name_row = next_row();
|
||||
draw_gutter(f, name_row, "name:");
|
||||
@@ -1681,7 +1711,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane::default();
|
||||
let mut draft = DraftPane {
|
||||
focused_field: DraftField::Preset,
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.on_input(&InputKey::Right);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
draft.on_input(&InputKey::Left);
|
||||
@@ -1704,17 +1737,22 @@ mod tests {
|
||||
draft.selected_preset = draft.presets.len() - 1;
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(
|
||||
draft.field_order()[..2],
|
||||
[DraftField::Preset, DraftField::CustomCommand],
|
||||
"custom command sits between presets and the dir picker"
|
||||
draft.field_order()[..3],
|
||||
[
|
||||
DraftField::DirPicker,
|
||||
DraftField::Preset,
|
||||
DraftField::CustomCommand
|
||||
],
|
||||
"the picker leads; custom command sits right after the tiles"
|
||||
);
|
||||
draft.next_field(); // DirPicker → Preset
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::CustomCommand);
|
||||
typed(&mut draft, "bash -l");
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
// Tab walks past the hidden fields in order.
|
||||
draft.next_field();
|
||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
||||
assert_eq!(draft.focused_field, DraftField::Name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1777,8 +1815,11 @@ mod tests {
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.next_field(); // Preset → DirPicker
|
||||
assert_eq!(draft.focused_field, DraftField::DirPicker);
|
||||
assert_eq!(
|
||||
draft.focused_field,
|
||||
DraftField::DirPicker,
|
||||
"the form opens with the project filter focused"
|
||||
);
|
||||
typed(&mut draft, "maes");
|
||||
assert_eq!(
|
||||
draft.visible_groups().len(),
|
||||
@@ -1890,6 +1931,47 @@ mod tests {
|
||||
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
|
||||
let mut draft = DraftPane {
|
||||
seed_cwd: Some(PathBuf::from("/repos/other")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![
|
||||
project("maestro", "/repos/maestro", 1, Some("claude")),
|
||||
project("other", "/repos/other", 2, Some("zsh")),
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(
|
||||
draft.picked_cwd.as_deref(),
|
||||
Some(Path::new("/repos/other")),
|
||||
"the seeded project is picked before a key is pressed"
|
||||
);
|
||||
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
|
||||
assert_eq!(
|
||||
draft.on_input(&InputKey::Enter),
|
||||
DraftInputResult::Submit,
|
||||
"one Enter spawns in the seeded project"
|
||||
);
|
||||
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
|
||||
let mut draft = DraftPane {
|
||||
seed_cwd: Some(PathBuf::from("/gone")),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_projects(
|
||||
vec![project("maestro", "/repos/maestro", 1, None)],
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(draft.picked_cwd, None);
|
||||
assert_eq!(draft.selection(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
|
||||
let mut draft = DraftPane::default();
|
||||
@@ -2140,7 +2222,7 @@ mod tests {
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||
let pos = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
|
||||
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,16 +26,25 @@ pub struct FastForwardDefaults {
|
||||
|
||||
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
||||
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
||||
/// caller falls back to the built-ins it already has).
|
||||
/// caller falls back to the built-ins it already has). `focused_cwd` is the
|
||||
/// project of the session the operator was last on ([`crate::app`]'s
|
||||
/// `focused_seed_cwd`) — when it matches a known project, that project wins
|
||||
/// over the global recency ranking, so `N` lands where the operator
|
||||
/// currently is rather than wherever was freshest.
|
||||
pub fn resolve(
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
name: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
focused_cwd: Option<&std::path::Path>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next();
|
||||
let project = focused_cwd
|
||||
.and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
|
||||
.or_else(|| {
|
||||
crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next()
|
||||
});
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
@@ -155,7 +164,13 @@ mod tests {
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&presets,
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
@@ -176,6 +191,46 @@ mod tests {
|
||||
assert!(wt.create_branch, "fresh branch named after the session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_sessions_project_wins_over_the_freshest_one() {
|
||||
let projects = vec![
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
project("/repos/current", 5, Some("zsh")),
|
||||
];
|
||||
let presets = vec![preset("claude", "claude")];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&presets,
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
Some(std::path::Path::new("/repos/current")),
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/current")
|
||||
);
|
||||
assert_eq!(r.command.as_deref(), Some("zsh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() {
|
||||
let projects = vec![
|
||||
project("/repos/old", 30, Some("zsh")),
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
Some(std::path::Path::new("/repos/gone")),
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
||||
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
||||
@@ -184,6 +239,7 @@ mod tests {
|
||||
&[preset("zsh", "zsh")],
|
||||
Some("s".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let wt = r.build_request().unwrap().worktree.unwrap();
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
||||
@@ -193,7 +249,7 @@ mod tests {
|
||||
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
||||
let projects = vec![project("/repos/p", 0, None)];
|
||||
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
@@ -201,7 +257,7 @@ mod tests {
|
||||
fn an_empty_last_command_is_no_command_at_all() {
|
||||
let projects = vec![project("/repos/p", 0, Some(""))];
|
||||
let presets = vec![preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||
assert_eq!(r.command, None);
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
@@ -213,6 +269,7 @@ mod tests {
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(r.project, None);
|
||||
// No project → no directory → no request, even with command + name.
|
||||
@@ -222,14 +279,26 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_generated_name_downgrades_to_a_draft() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
||||
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
||||
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("aider", "aider")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
@@ -251,6 +320,7 @@ mod tests {
|
||||
&[preset("claude", "claude")],
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
named.apply_to_draft(&mut draft);
|
||||
@@ -263,7 +333,13 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
@@ -273,7 +349,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
||||
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.command(), "nvim -d");
|
||||
|
||||
@@ -13,15 +13,17 @@
|
||||
//!
|
||||
//! Each surface carries its own activity colours. A state colour picked for
|
||||
//! the dark card background is not legible on the selection background, so
|
||||
//! the selected card uses the selection surface's own state colours; the dot
|
||||
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//! the selected card uses the selection surface's own state colours; the
|
||||
//! rail keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//!
|
||||
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
|
||||
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
|
||||
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
|
||||
//! colours lifted or darkened where the originals fall short of the contrast
|
||||
//! floors asserted below.
|
||||
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
|
||||
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
|
||||
//! terminal-provided ANSI colours. Five house presets recolour the graphite
|
||||
//! base with an accent (Ice Cyan, Periwinkle, Amber Phosphor, Ember, Orchid);
|
||||
//! the remaining presets ([`Palette::presets`]) adapt well-known editor
|
||||
//! themes — Solarized, Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord,
|
||||
//! Dracula — with individual colours lifted or darkened where the originals
|
||||
//! fall short of the contrast floors asserted below.
|
||||
//!
|
||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||
//! pane and pass through `pane::translate_color` verbatim.
|
||||
@@ -135,73 +137,75 @@ pub struct Palette {
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The dark terminal-native palette and the default preset.
|
||||
/// The graphite dark palette and the default preset: pure near-neutral
|
||||
/// chrome with an ink-coloured accent, so activity colours are the only
|
||||
/// hue on screen.
|
||||
pub const DARK: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
|
||||
muted: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||
dim: Color::Rgb(0x85, 0x8f, 0x9e),
|
||||
bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
fg: Color::Rgb(0xe6, 0xe8, 0xeb),
|
||||
muted: Color::Rgb(0xb1, 0xb4, 0xb9),
|
||||
dim: Color::Rgb(0x86, 0x89, 0x8e),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||
idle: Color::Rgb(0xb1, 0xb4, 0xb9),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x3d, 0x51, 0x44),
|
||||
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
|
||||
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
|
||||
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
|
||||
bg: Color::Rgb(0x3f, 0x43, 0x48),
|
||||
fg: Color::Rgb(0xf0, 0xf2, 0xf5),
|
||||
muted: Color::Rgb(0xce, 0xd1, 0xd5),
|
||||
dim: Color::Rgb(0xa5, 0xa8, 0xab),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
|
||||
idle: Color::Rgb(0xd5, 0xd8, 0xdb),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
|
||||
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
|
||||
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
chrome_bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
sidebar_focus_bg: Color::Rgb(0x28, 0x2a, 0x2d),
|
||||
border_focused: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
border_unfocused: Color::Rgb(0x72, 0x75, 0x79),
|
||||
focus_accent: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
group_row: Color::Rgb(0xba, 0xbe, 0xc3),
|
||||
mode_normal_bg: Color::Rgb(0xe2, 0xe5, 0xe8),
|
||||
mode_normal_fg: Color::Rgb(0x17, 0x1b, 0x20),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
mode_insert_fg: Color::Rgb(0x1d, 0x1f, 0x22),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// A high-contrast light peer to [`Self::DARK`].
|
||||
/// A high-contrast light peer to [`Self::DARK`]: ink-on-paper graphite.
|
||||
pub const LIGHT: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
fg: Color::Rgb(0x27, 0x31, 0x3d),
|
||||
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||
dim: Color::Rgb(0x68, 0x78, 0x8a),
|
||||
fg: Color::Rgb(0x20, 0x24, 0x2a),
|
||||
muted: Color::Rgb(0x56, 0x5b, 0x63),
|
||||
dim: Color::Rgb(0x7b, 0x81, 0x89),
|
||||
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||
idle: Color::Rgb(0x56, 0x5b, 0x63),
|
||||
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
|
||||
error: Color::Rgb(0xb4, 0x23, 0x18),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0xb5, 0xcf, 0xba),
|
||||
fg: Color::Rgb(0x1d, 0x2a, 0x24),
|
||||
muted: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
dim: Color::Rgb(0x52, 0x68, 0x5a),
|
||||
bg: Color::Rgb(0xc5, 0xc7, 0xca),
|
||||
fg: Color::Rgb(0x15, 0x19, 0x1e),
|
||||
muted: Color::Rgb(0x43, 0x48, 0x4f),
|
||||
dim: Color::Rgb(0x5e, 0x64, 0x6b),
|
||||
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
||||
idle: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
idle: Color::Rgb(0x38, 0x3e, 0x45),
|
||||
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
||||
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||
error: Color::Rgb(0x99, 0x1a, 0x12),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
|
||||
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
group_row: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
|
||||
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
|
||||
sidebar_focus_bg: Color::Rgb(0xef, 0xf0, 0xf2),
|
||||
border_focused: Color::Rgb(0x36, 0x3b, 0x41),
|
||||
border_unfocused: Color::Rgb(0x76, 0x7b, 0x82),
|
||||
focus_accent: Color::Rgb(0x36, 0x3b, 0x41),
|
||||
group_row: Color::Rgb(0x4e, 0x53, 0x5a),
|
||||
mode_normal_bg: Color::Rgb(0x23, 0x27, 0x2c),
|
||||
mode_normal_fg: Color::Rgb(0xf9, 0xfa, 0xfb),
|
||||
mode_insert_bg: Color::Rgb(0x8f, 0x63, 0x00),
|
||||
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
||||
};
|
||||
|
||||
@@ -556,14 +560,195 @@ impl Palette {
|
||||
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
||||
};
|
||||
|
||||
/// Ice Cyan — signal-cyan accent on the graphite slate base.
|
||||
pub const ICE_CYAN: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
fg: Color::Rgb(0xe4, 0xe8, 0xed),
|
||||
muted: Color::Rgb(0xaf, 0xb5, 0xbc),
|
||||
dim: Color::Rgb(0x84, 0x8a, 0x91),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xaf, 0xb5, 0xbc),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x24, 0x4d, 0x59),
|
||||
fg: Color::Rgb(0xee, 0xf2, 0xf7),
|
||||
muted: Color::Rgb(0xcd, 0xd1, 0xd7),
|
||||
dim: Color::Rgb(0xa4, 0xa8, 0xae),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd3, 0xd8, 0xde),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
sidebar_focus_bg: Color::Rgb(0x26, 0x2a, 0x30),
|
||||
border_focused: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
border_unfocused: Color::Rgb(0x70, 0x75, 0x7b),
|
||||
focus_accent: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
group_row: Color::Rgb(0xb8, 0xbe, 0xc6),
|
||||
mode_normal_bg: Color::Rgb(0x61, 0xe3, 0xf1),
|
||||
mode_normal_fg: Color::Rgb(0x0f, 0x22, 0x2b),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1c, 0x20, 0x25),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Periwinkle — indigo-tinted base with a soft blue-violet accent.
|
||||
pub const PERIWINKLE: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
fg: Color::Rgb(0xe5, 0xe7, 0xf1),
|
||||
muted: Color::Rgb(0xb1, 0xb3, 0xc2),
|
||||
dim: Color::Rgb(0x86, 0x88, 0x96),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xb1, 0xb3, 0xc2),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x3b, 0x43, 0x6c),
|
||||
fg: Color::Rgb(0xef, 0xf1, 0xfb),
|
||||
muted: Color::Rgb(0xce, 0xd0, 0xdc),
|
||||
dim: Color::Rgb(0xa5, 0xa7, 0xb2),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd4, 0xd7, 0xe3),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
sidebar_focus_bg: Color::Rgb(0x27, 0x29, 0x35),
|
||||
border_focused: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
border_unfocused: Color::Rgb(0x71, 0x74, 0x80),
|
||||
focus_accent: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
group_row: Color::Rgb(0xba, 0xbd, 0xcc),
|
||||
mode_normal_bg: Color::Rgb(0x97, 0xa7, 0xfd),
|
||||
mode_normal_fg: Color::Rgb(0x12, 0x14, 0x28),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1d, 0x1e, 0x29),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Amber Phosphor — CRT amber on warm graphite. The awaiting colour
|
||||
/// shifts orange so it stays distinct from the amber accent.
|
||||
pub const AMBER_PHOSPHOR: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
fg: Color::Rgb(0xec, 0xe7, 0xe1),
|
||||
muted: Color::Rgb(0xba, 0xb3, 0xaa),
|
||||
dim: Color::Rgb(0x8f, 0x88, 0x7f),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xba, 0xb3, 0xaa),
|
||||
awaiting: Color::Rgb(0xff, 0x9b, 0x50),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x55, 0x43, 0x26),
|
||||
fg: Color::Rgb(0xf6, 0xf1, 0xeb),
|
||||
muted: Color::Rgb(0xd6, 0xd0, 0xc9),
|
||||
dim: Color::Rgb(0xad, 0xa7, 0xa0),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xdd, 0xd6, 0xcf),
|
||||
awaiting: Color::Rgb(0xff, 0xb9, 0x72),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
sidebar_focus_bg: Color::Rgb(0x2d, 0x29, 0x25),
|
||||
border_focused: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
border_unfocused: Color::Rgb(0x7a, 0x73, 0x6c),
|
||||
focus_accent: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
group_row: Color::Rgb(0xc4, 0xbc, 0xb3),
|
||||
mode_normal_bg: Color::Rgb(0xf9, 0xbb, 0x51),
|
||||
mode_normal_fg: Color::Rgb(0x2f, 0x1e, 0x01),
|
||||
mode_insert_bg: Color::Rgb(0xff, 0x9b, 0x50),
|
||||
mode_insert_fg: Color::Rgb(0x22, 0x1f, 0x1a),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Ember — warm orange-red accent on a cleaner warm neutral.
|
||||
pub const EMBER: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
fg: Color::Rgb(0xed, 0xe6, 0xe3),
|
||||
muted: Color::Rgb(0xbc, 0xb2, 0xad),
|
||||
dim: Color::Rgb(0x90, 0x87, 0x82),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xbc, 0xb2, 0xad),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x5d, 0x3e, 0x30),
|
||||
fg: Color::Rgb(0xf7, 0xf0, 0xed),
|
||||
muted: Color::Rgb(0xd7, 0xcf, 0xcb),
|
||||
dim: Color::Rgb(0xae, 0xa6, 0xa2),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xde, 0xd5, 0xd1),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
sidebar_focus_bg: Color::Rgb(0x2e, 0x29, 0x26),
|
||||
border_focused: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
border_unfocused: Color::Rgb(0x7b, 0x72, 0x6e),
|
||||
focus_accent: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
group_row: Color::Rgb(0xc6, 0xbb, 0xb6),
|
||||
mode_normal_bg: Color::Rgb(0xf7, 0x89, 0x55),
|
||||
mode_normal_fg: Color::Rgb(0x2a, 0x14, 0x0a),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x23, 0x1e, 0x1b),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// Orchid — muted violet accent on a faintly violet base.
|
||||
pub const ORCHID: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
fg: Color::Rgb(0xe9, 0xe6, 0xee),
|
||||
muted: Color::Rgb(0xb6, 0xb2, 0xbd),
|
||||
dim: Color::Rgb(0x8b, 0x87, 0x91),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xb6, 0xb2, 0xbd),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x51, 0x3c, 0x63),
|
||||
fg: Color::Rgb(0xf3, 0xf0, 0xf8),
|
||||
muted: Color::Rgb(0xd3, 0xcf, 0xd8),
|
||||
dim: Color::Rgb(0xa9, 0xa6, 0xaf),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xd9, 0xd5, 0xdf),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
sidebar_focus_bg: Color::Rgb(0x2c, 0x28, 0x30),
|
||||
border_focused: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
border_unfocused: Color::Rgb(0x76, 0x72, 0x7c),
|
||||
focus_accent: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
group_row: Color::Rgb(0xc0, 0xbb, 0xc7),
|
||||
mode_normal_bg: Color::Rgb(0xd1, 0x9f, 0xf5),
|
||||
mode_normal_fg: Color::Rgb(0x22, 0x13, 0x2c),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x21, 0x1e, 0x25),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// The default preset, retained as a named compatibility constant.
|
||||
pub const DEFAULT: Palette = Self::DARK;
|
||||
|
||||
/// All preset names and their complete palettes.
|
||||
pub fn presets() -> [(&'static str, Palette); 12] {
|
||||
pub fn presets() -> [(&'static str, Palette); 17] {
|
||||
[
|
||||
("dark", Self::DARK),
|
||||
("light", Self::LIGHT),
|
||||
("ice-cyan", Self::ICE_CYAN),
|
||||
("periwinkle", Self::PERIWINKLE),
|
||||
("amber-phosphor", Self::AMBER_PHOSPHOR),
|
||||
("ember", Self::EMBER),
|
||||
("orchid", Self::ORCHID),
|
||||
("ansi", Self::ANSI),
|
||||
("solarized-dark", Self::SOLARIZED_DARK),
|
||||
("solarized-light", Self::SOLARIZED_LIGHT),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
|
||||
//!
|
||||
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity rail + duration in the state's colour, plus the
|
||||
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30;
|
||||
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||
const INDENT: &str = " ";
|
||||
const META_INDENT: &str = " ";
|
||||
/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on
|
||||
/// 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
|
||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||
@@ -203,7 +206,9 @@ impl Sidebar {
|
||||
);
|
||||
let chevron = Span::styled(chevron, base);
|
||||
let count = Span::styled(count, surface.muted_style().bg(row_bg));
|
||||
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
||||
// "1 +" is the blank leading column matching the session rows' rail.
|
||||
let label_budget =
|
||||
width.saturating_sub(1 + prefix.width() + chevron.width() + count.width());
|
||||
let mut label = Vec::new();
|
||||
if let Some(stem) = name.stem {
|
||||
label.push(Span::styled(
|
||||
@@ -213,7 +218,9 @@ impl Sidebar {
|
||||
}
|
||||
label.push(Span::styled(name.last, base));
|
||||
|
||||
let mut spans = vec![prefix, chevron];
|
||||
// A blank leading column, matching the width the activity rail adds
|
||||
// to session rows, so folder labels keep aligning under names.
|
||||
let mut spans = vec![Span::styled(" ", base), prefix, chevron];
|
||||
spans.extend(fit_to_width(label, label_budget));
|
||||
spans.push(count);
|
||||
pad_to_width(spans, width, base)
|
||||
@@ -227,11 +234,13 @@ impl Sidebar {
|
||||
};
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let prefix = INDENT.chars().count() + 2;
|
||||
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 mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
if edge { "▏ " } else { INDENT },
|
||||
if edge {
|
||||
@@ -250,7 +259,8 @@ impl Sidebar {
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||
/// The adaptive second line: `[<duration>] [<detail>] <command> [<blocked work> | exited]`,
|
||||
/// led by the activity rail (docs/SIDEBAR.md).
|
||||
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let now = Utc::now();
|
||||
let held = held_for(s, now);
|
||||
@@ -261,7 +271,7 @@ impl Sidebar {
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let dot_style =
|
||||
let rail_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
@@ -269,6 +279,7 @@ impl Sidebar {
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
if edge { " ▏ " } else { META_INDENT },
|
||||
if edge {
|
||||
@@ -277,11 +288,9 @@ impl Sidebar {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
spans.push(Span::styled(format_state_duration(d), rail_style));
|
||||
}
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
@@ -320,8 +329,8 @@ impl Sidebar {
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
spans.extend(fit_to_width(vec![command], command_w));
|
||||
spans.extend(tail);
|
||||
spans
|
||||
}
|
||||
}
|
||||
@@ -689,7 +698,7 @@ mod tests {
|
||||
|
||||
/// The additions and deletions are the only coloured fragments: they are
|
||||
/// what a reader scans for, and colouring the rest would make the line
|
||||
/// compete with the activity dot.
|
||||
/// compete with the activity rail.
|
||||
#[test]
|
||||
fn the_diff_colours_only_its_additions_and_deletions() {
|
||||
let mut s = session_named(1, "claude");
|
||||
@@ -735,8 +744,8 @@ mod tests {
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[3].contains(RAIL),
|
||||
"line 2 must carry the rail: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
@@ -752,7 +761,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
|
||||
/// meta line, with the floored duration next to the dot.
|
||||
/// meta line, with the floored duration next to the rail.
|
||||
#[test]
|
||||
fn long_held_session_gets_a_meta_line_with_duration() {
|
||||
let mut s = session_named(1, "claude");
|
||||
@@ -765,7 +774,7 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
||||
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
|
||||
}
|
||||
|
||||
@@ -782,10 +791,10 @@ mod tests {
|
||||
assert_eq!(fmt(7200), "2h");
|
||||
}
|
||||
|
||||
/// The dot and duration carry the activity state's colour; the state word
|
||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||
/// The rail and duration carry the activity state's colour; the state
|
||||
/// word is never spelled out (docs/SIDEBAR.md colour table).
|
||||
#[test]
|
||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||
fn meta_line_rail_uses_the_contract_colour_per_state() {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
@@ -808,7 +817,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected card keeps carrying its state on the dot, but in the
|
||||
/// The selected card keeps carrying its state on the rail, but in the
|
||||
/// selection surface's own colours. A state colour picked for the dark
|
||||
/// card background painted onto the selection background is the contrast
|
||||
/// bug this palette fixes — this test fails if it comes back.
|
||||
@@ -845,7 +854,7 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
on_selection(p.selection.activity(state)),
|
||||
"{state:?}: the dot must still carry the state on the selected card"
|
||||
"{state:?}: the rail must still carry the state on the selected card"
|
||||
);
|
||||
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
|
||||
.expect("palette roles are RGB");
|
||||
@@ -918,9 +927,11 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
let ellipsis = meta.find('…').expect("truncated command must contain …");
|
||||
let tail = meta.find("~120").expect("diff stat must survive: {meta:?}");
|
||||
assert!(
|
||||
meta.trim_end().ends_with('…'),
|
||||
"truncated command must end with …: {meta:?}"
|
||||
ellipsis < tail,
|
||||
"command must truncate before the diff stat: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
@@ -945,7 +956,7 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("1m"),
|
||||
"duration precedes the detail: {meta:?}"
|
||||
@@ -1018,7 +1029,7 @@ mod tests {
|
||||
|
||||
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
||||
/// `activity_since` and so contributes no duration — a card must not claim
|
||||
/// the session has been idle since 1970. The dot still renders when some
|
||||
/// the session has been idle since 1970. The rail still renders when some
|
||||
/// other trigger puts line 2 on screen.
|
||||
#[test]
|
||||
fn epoch_activity_since_renders_no_duration() {
|
||||
@@ -1035,10 +1046,10 @@ mod tests {
|
||||
rendered.contains("exited"),
|
||||
"dead session must still show line 2:\n{rendered}"
|
||||
);
|
||||
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||
for line in rendered.lines().filter(|l| l.contains(RAIL)) {
|
||||
assert!(
|
||||
!line
|
||||
.split('●')
|
||||
.split(RAIL)
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim_start()
|
||||
@@ -1101,7 +1112,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_meta_line_keeps_dot_and_truncates_command() {
|
||||
fn narrow_meta_line_keeps_rail_and_truncates_command() {
|
||||
let s = session_named(1, "name");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
@@ -1110,8 +1121,8 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 9, 6));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[3].contains(RAIL),
|
||||
"rail must survive narrow cards: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
@@ -1122,7 +1133,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_warning_survives_before_a_truncated_command() {
|
||||
fn blocked_warning_survives_after_a_truncated_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.command = "a-program-name-that-cannot-fit".into();
|
||||
s.blocked_work = Some(InTheWay {
|
||||
@@ -1137,7 +1148,12 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
let meta = content(&all[3]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
let ellipsis = meta.find('…').expect("command must truncate");
|
||||
let tail = meta.find("~1").unwrap();
|
||||
assert!(
|
||||
ellipsis < tail,
|
||||
"diff stat must follow the truncated command: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-6
@@ -154,15 +154,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
# want extra Insert-mode bindings)
|
||||
```
|
||||
|
||||
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||
The remaining presets adapt well-known editor themes, with individual colours
|
||||
lifted or darkened where the originals fall short of the WCAG AA contrast
|
||||
floors the palette tests assert:
|
||||
`theme` selects the complete TUI palette. `dark` is the default: Maestro's
|
||||
graphite palette, near-neutral chrome where the activity colours are the only
|
||||
hue on screen; `light` is its light peer; `ansi` uses the terminal's named
|
||||
ANSI colours for terminals without truecolour. Five house presets recolour
|
||||
the graphite base with an accent, and the remaining presets adapt well-known
|
||||
editor themes — in both groups individual colours are lifted or darkened
|
||||
where they would fall short of the WCAG AA contrast floors the palette tests
|
||||
assert:
|
||||
|
||||
| name | based on |
|
||||
|-------------------|------------------|
|
||||
| `ice-cyan` | graphite base, signal-cyan accent |
|
||||
| `periwinkle` | indigo base, blue-violet accent |
|
||||
| `amber-phosphor` | warm graphite, CRT-amber accent |
|
||||
| `ember` | warm graphite, orange-red accent |
|
||||
| `orchid` | violet base, muted violet accent |
|
||||
| `solarized-dark` | Solarized Dark |
|
||||
| `solarized-light` | Solarized Light |
|
||||
| `catppuccin` | Catppuccin Mocha |
|
||||
|
||||
+62
-12
@@ -37,23 +37,47 @@ itself is not.
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
|
||||
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
Its minimum content is therefore just the command.
|
||||
|
||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||
is no state word and no second badge: the dot exists so the state is still
|
||||
legible when a row is dimmed or the terminal renders the duration in the
|
||||
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||
fragments appear only when their trigger holds.
|
||||
The activity state does not render as an inline glyph on line 2 — a **rail**
|
||||
carries it instead: a coloured bar in the gutter to the left of the card, the
|
||||
space nesting already reserves for indentation. The rail paints for every
|
||||
card, `Idle` included, and is independent of whatever line 2 has to say; a
|
||||
card with nothing else to report still shows its rail next to the bare
|
||||
command. `<duration>` follows in the rail's colour when shown. There is no
|
||||
state word and no second badge: the rail exists so the state is still legible
|
||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||
|
||||
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||
edge, square corners, spanning the full card height edge to edge — it
|
||||
shifts outward with nesting depth along with the row's own padding, since
|
||||
it is positioned relative to the row. It carries motion only for states that
|
||||
need attention (DESIGN.md's "living signals" rule): a hard blink while
|
||||
`AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
|
||||
solid and still — colour alone (green vs. the idle grey) is the signal for
|
||||
`Active`, so it does not also pulse. `prefers-reduced-motion` leaves every
|
||||
state as a solid bar.
|
||||
|
||||
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
|
||||
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
|
||||
both the name row and the meta row, so the two lines read as one continuous
|
||||
bar. Folder headers gain a matching blank leading column so labels still
|
||||
align under session names. The TUI has no render cadence to animate against,
|
||||
so its rail is always static — colour is the whole signal there, as it
|
||||
always was.
|
||||
|
||||
Either client's rail carries the accessibility duty the old inline dot did:
|
||||
it is the element whose `title`/`aria-label` names the state, so status still
|
||||
never relies on colour alone.
|
||||
|
||||
`<duration>` renders only once the session has been held in its current
|
||||
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||
that threshold the duration is absent and the dot renders alone. A payload
|
||||
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||
duration — the dot still renders. This rule now governs only the duration
|
||||
that threshold the duration is absent and the rail carries the state alone. A
|
||||
payload that predates #102 (no usable `activity_since`) likewise contributes
|
||||
no duration — the rail still renders. This rule now governs only the duration
|
||||
fragment; it no longer decides whether line 2 exists.
|
||||
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
@@ -61,7 +85,7 @@ fragment; it no longer decides whether line 2 exists.
|
||||
while an agent-reported state is in force (see Precedence): when the report
|
||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
@@ -187,6 +211,32 @@ arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
|
||||
state is a 4xx: an agent posting a state the daemon does not know is a bug in
|
||||
the agent, and answering 204 would hide it.
|
||||
|
||||
## Focus clears `AwaitingInput`
|
||||
|
||||
A session's pane — the pane a client's own attach view is showing, never a
|
||||
sidebar highlight — clears its `AwaitingInput` glow once it has been
|
||||
**continuously focused for 5 seconds**: the operator has looked, so the rail
|
||||
stops asking for attention and the card reads as `Idle` instead. This is a
|
||||
real state change, not a client-side paint-over — the daemon computes it, so
|
||||
every client watching the session (web, TUI, a second pane on the same
|
||||
session) sees the same colour.
|
||||
|
||||
The acknowledgement is sticky: it survives losing focus, and holds until the
|
||||
session is genuinely `Active` again — new work, not merely a refocus. Only
|
||||
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
|
||||
Unfocusing and refocusing alone never re-arms it.
|
||||
|
||||
This applies uniformly to `AwaitingInput` from either source — the daemon's
|
||||
own inference or an agent's report (#105) — since the rail draws them
|
||||
identically. It does not touch `Error`; that state's own TTL (below) is
|
||||
unrelated and unaffected by focus.
|
||||
|
||||
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
|
||||
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
|
||||
switching which session a pane shows, and on leaving the pane. A client
|
||||
sends `false` for the session it just left before `true` for the session it
|
||||
lands on, so a click that swaps panes never briefly reports both focused.
|
||||
|
||||
## Keyboard walk
|
||||
|
||||
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients
|
||||
|
||||
@@ -91,6 +91,22 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
* (docs/SIDEBAR.md). Best-effort: a failure here only delays the glow
|
||||
* clearing, so callers don't need to surface it. */
|
||||
export async function setSessionFocus(id: Uuid, focused: boolean): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.sessionFocus(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ focused }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
|
||||
* `~`, relative, or absolute — the daemon resolves it against its home dir. */
|
||||
export async function listDir(path: string): Promise<DirListing> {
|
||||
|
||||
+42
-7
@@ -3,9 +3,10 @@ import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { applyModsToChar, cursorSeq } from "./keys.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
@@ -134,6 +135,15 @@ export function createAttachConnection(
|
||||
term.loadAddon(fit);
|
||||
term.open(body);
|
||||
|
||||
// Plain Escape hands the keyboard back to the session list instead of the
|
||||
// shell/app. `false` tells xterm to skip its own handling of this event.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
||||
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
||||
return false;
|
||||
});
|
||||
|
||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||
// • normal buffer → term.scrollLines walks the scrollback.
|
||||
@@ -433,11 +443,17 @@ export function createAttachConnection(
|
||||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
||||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
||||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
||||
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
|
||||
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
|
||||
* - `char` is a literal character (the symbols the soft keyboard buries behind
|
||||
* a page switch), folded through a pending modifier like typed input.
|
||||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
||||
*/
|
||||
type KeyDef =
|
||||
| { label: string; mod: Modifier }
|
||||
| { label: string; cursor: CursorFinal }
|
||||
| { label: string; tilde: number }
|
||||
| { label: string; char: string }
|
||||
| { label: string; seq: string };
|
||||
|
||||
type Modifier = "ctrl" | "alt";
|
||||
@@ -445,14 +461,17 @@ type Modifier = "ctrl" | "alt";
|
||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||
|
||||
/**
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into a
|
||||
* 5-column / 2-row grid (NAP-342 review):
|
||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
|
||||
* 8-column / 2-row grid (#7):
|
||||
*
|
||||
* Esc Tab Home ↑ End
|
||||
* Ctrl Alt ← ↓ →
|
||||
* Esc Tab Home ↑ End PgUp - /
|
||||
* Ctrl Alt ← ↓ → PgDn | ~
|
||||
*
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as
|
||||
* a real-keyboard inverted-T on the right, with Home/End filling its top corners.
|
||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
|
||||
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
|
||||
* then the paging pair and the shell symbols that cost a soft-keyboard page
|
||||
* switch to reach. Narrower screens scroll the bar sideways rather than
|
||||
* shrinking the caps below a thumb's width.
|
||||
*/
|
||||
const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Esc", seq: "\x1b" },
|
||||
@@ -460,11 +479,17 @@ const KEY_DEFS: KeyDef[] = [
|
||||
{ label: "Home", cursor: "H" },
|
||||
{ label: "↑", cursor: "A" },
|
||||
{ label: "End", cursor: "F" },
|
||||
{ label: "PgUp", tilde: 5 },
|
||||
{ label: "-", char: "-" },
|
||||
{ label: "/", char: "/" },
|
||||
{ label: "Ctrl", mod: "ctrl" },
|
||||
{ label: "Alt", mod: "alt" },
|
||||
{ label: "←", cursor: "D" },
|
||||
{ label: "↓", cursor: "B" },
|
||||
{ label: "→", cursor: "C" },
|
||||
{ label: "PgDn", tilde: 6 },
|
||||
{ label: "|", char: "|" },
|
||||
{ label: "~", char: "~" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -521,6 +546,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar {
|
||||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("tilde" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else if ("char" in def) {
|
||||
btn.addEventListener("click", () => {
|
||||
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
|
||||
clearMods();
|
||||
});
|
||||
} else {
|
||||
btn.addEventListener("click", () => {
|
||||
send(def.seq);
|
||||
|
||||
@@ -224,9 +224,10 @@ describe("session cards (#107)", () => {
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
||||
});
|
||||
@@ -238,9 +239,10 @@ describe("session cards (#107)", () => {
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
@@ -355,9 +357,9 @@ describe("session cards (#107)", () => {
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const dot = row.querySelector<HTMLElement>(".activity-dot");
|
||||
expect(dot?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
const rail = row.querySelector<HTMLElement>(".activity-rail");
|
||||
expect(rail?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||
});
|
||||
|
||||
|
||||
+33
-10
@@ -24,7 +24,7 @@ import {
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
import type { DaemonLink } from "./daemonLink.js";
|
||||
import { createStatusIndicator } from "./statusIndicator.js";
|
||||
|
||||
@@ -36,14 +36,26 @@ export function requestOpenSpawn(): void {
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Escape in the sidebar list does. Handled in main.ts, which owns the
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the session list — what Escape
|
||||
* does when the pane's terminal is focused. Handled in main.ts. */
|
||||
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||
|
||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
* matches fires no `hashchange` and the app never hears about it. This
|
||||
* event is the click-driven path that doesn't depend on the hash actually
|
||||
* changing. */
|
||||
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
||||
|
||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
||||
* folder headers are skipped: a directory is not something to focus, and the
|
||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
||||
@@ -58,8 +70,9 @@ const collapsed = new Set<string>();
|
||||
* would otherwise replace the field the user is typing into. */
|
||||
let renamingId: string | null = null;
|
||||
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
|
||||
// carries it. It survives only in tooltips/aria labels so meaning is not
|
||||
// colour-only.
|
||||
const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
@@ -67,24 +80,29 @@ const STATE_WORDS: Record<string, string> = {
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||
* nesting depth, carrying the rail with it. */
|
||||
function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
||||
}
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
commandFragment(s.command),
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
@@ -156,14 +174,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
const stateWord =
|
||||
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
${activityRailMarkup(slug, stateWord)}
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
@@ -185,6 +204,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
ev.stopPropagation();
|
||||
void requestKill(s, name);
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -385,6 +407,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
footer.appendChild(helpBtn);
|
||||
footer.appendChild(createThemeToggle());
|
||||
footer.appendChild(createPaletteSelect());
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -549,7 +572,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||
else if (e.key === "Enter") activateNav();
|
||||
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||
|
||||
function mockFetch() {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("ConnectionPool.setFocus", () => {
|
||||
it("reports the new session focused, none unfocused, the first time", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
|
||||
pool.setFocus(A);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
|
||||
});
|
||||
|
||||
it("unfocuses the previous session and focuses the new one on a switch", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
pool.setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.setFocus(B);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
|
||||
url,
|
||||
JSON.parse((init as RequestInit).body as string),
|
||||
]);
|
||||
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
|
||||
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
|
||||
});
|
||||
|
||||
it("reports unfocused when focus clears to null", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
pool.setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.setFocus(null);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
|
||||
});
|
||||
|
||||
it("is a no-op when the focused session doesn't change", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
pool.setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.setFocus(A);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a fetch rejection break the pool", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||
);
|
||||
const pool = new ConnectionPool();
|
||||
|
||||
expect(() => pool.setFocus(A)).not.toThrow();
|
||||
// Let the rejected promise's `.catch` settle before the test ends.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AttachConnection,
|
||||
type AttachInfo,
|
||||
} from "./attach.js";
|
||||
import { setSessionFocus } from "./api.js";
|
||||
import type { Uuid } from "./types.js";
|
||||
|
||||
/**
|
||||
@@ -122,9 +123,16 @@ export class ConnectionPool {
|
||||
for (const e of entries) this.mount(e.id, e.pane, e.info);
|
||||
}
|
||||
|
||||
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
||||
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
|
||||
* only a pane actually becoming the visible view. Best-effort: a failed
|
||||
* report only delays the glow clearing. */
|
||||
setFocus(id: Uuid | null): void {
|
||||
if (id === this.focusedId) return;
|
||||
const previous = this.focusedId;
|
||||
this.focusedId = id;
|
||||
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
|
||||
if (id !== null) void setSessionFocus(id, true).catch(() => {});
|
||||
this.opts.onFocusChange?.(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the focused session's project over the freshest one", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }),
|
||||
project({
|
||||
path: "/repos/current",
|
||||
name: "current",
|
||||
last_activity: "2020-01-01T00:00:00Z",
|
||||
last_command: "zsh",
|
||||
}),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "n", "/repos/current");
|
||||
expect(res.project?.path).toBe("/repos/current");
|
||||
expect(res.command).toBe("zsh");
|
||||
});
|
||||
|
||||
it("falls back to the freshest project when the focused cwd is unknown", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
||||
project({ path: "/repos/fresh", name: "fresh" }),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "n", "/repos/gone");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
});
|
||||
|
||||
it("falls back to the first non-custom preset when there is no last command", () => {
|
||||
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
||||
expect(res.command).toBe("claude");
|
||||
|
||||
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** The defaults: most recent project (fresh-first, same ordering the picker
|
||||
* shows), its last command falling back to the first non-custom preset, and
|
||||
* a freshly generated name when the daemon supplied one. */
|
||||
/** The defaults: the currently focused session's project when it's still a
|
||||
* known project, else the most recent one (fresh-first, same ordering the
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project = sortProjects(projects, new Date())[0];
|
||||
const project =
|
||||
projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0];
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
||||
{ keys: ["j"], description: "Highlight the next session" },
|
||||
{ keys: ["k"], description: "Highlight the previous session" },
|
||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
|
||||
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq, toCtrl } from "./keys.js";
|
||||
|
||||
describe("toCtrl", () => {
|
||||
it("maps letters to their control code, case-insensitively", () => {
|
||||
@@ -43,3 +43,16 @@ describe("cursorSeq", () => {
|
||||
expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tildeSeq", () => {
|
||||
it("uses the bare CSI form when unmodified", () => {
|
||||
expect(tildeSeq(5, false, false)).toBe("\x1b[5~");
|
||||
expect(tildeSeq(6, false, false)).toBe("\x1b[6~");
|
||||
});
|
||||
|
||||
it("keeps the parameter in place when modified, unlike the cursor keys", () => {
|
||||
expect(tildeSeq(5, true, false)).toBe("\x1b[5;5~");
|
||||
expect(tildeSeq(6, false, true)).toBe("\x1b[6;3~");
|
||||
expect(tildeSeq(6, true, true)).toBe("\x1b[6;7~");
|
||||
});
|
||||
});
|
||||
|
||||
+14
-2
@@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string
|
||||
* pending — `\x1b[1;<n><final>`, n = 1 + alt(2) + ctrl(4) (xterm convention). */
|
||||
export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string {
|
||||
if (!ctrl && !alt) return `\x1b[${final}`;
|
||||
const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
return `\x1b[1;${n}${final}`;
|
||||
return `\x1b[1;${modParam(ctrl, alt)}${final}`;
|
||||
}
|
||||
|
||||
/** Modifier parameter of the xterm CSI forms: n = 1 + alt(2) + ctrl(4). */
|
||||
function modParam(ctrl: boolean, alt: boolean): number {
|
||||
return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
}
|
||||
|
||||
/** Sequence for the `~`-terminated navigation keys (PgUp `5`, PgDn `6`), which
|
||||
* carry modifiers as `\x1b[<code>;<n>~` rather than the cursor keys' `1;<n>`
|
||||
* form. */
|
||||
export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string {
|
||||
if (!ctrl && !alt) return `\x1b[${code}~`;
|
||||
return `\x1b[${code};${modParam(ctrl, alt)}~`;
|
||||
}
|
||||
|
||||
+14
-7
@@ -4,7 +4,7 @@
|
||||
// critiqued as a whole — buttons (including terminal-native variants for
|
||||
// review), inputs, chips, badges, session rows, and empty states.
|
||||
import "./kit.css";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
||||
|
||||
function h(tag: string, className?: string, html?: string): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
@@ -66,12 +66,15 @@ function leafRow(opts: {
|
||||
(row as HTMLAnchorElement).href = "#/kit";
|
||||
row.dataset.status = opts.status;
|
||||
if (opts.active) row.classList.add("is-active");
|
||||
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
||||
const depth = opts.indent ?? 0;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
const flags = [
|
||||
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
||||
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
||||
].join("");
|
||||
const activity = opts.activity ?? "idle";
|
||||
row.innerHTML = `
|
||||
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
<span class="leaf-actions">
|
||||
@@ -79,7 +82,7 @@ function leafRow(opts: {
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
@@ -102,7 +105,7 @@ export function renderKit(): HTMLElement {
|
||||
back.className = "btn btn-ghost";
|
||||
back.href = "?mock";
|
||||
back.textContent = "← back to app";
|
||||
headerActions.append(back, createThemeToggle());
|
||||
headerActions.append(back, createThemeToggle(), createPaletteSelect());
|
||||
header.appendChild(headerActions);
|
||||
page.appendChild(header);
|
||||
|
||||
@@ -113,7 +116,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Color tokens",
|
||||
"Slate neutrals (hue ~256) + signal-green = alive. Swap by theme toggle.",
|
||||
"Graphite neutrals; activity colours are the only hue. Swap by theme toggle + palette picker.",
|
||||
);
|
||||
body.classList.add("kit-swatches");
|
||||
const tokens = [
|
||||
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
|
||||
const activeLine = h(
|
||||
"span",
|
||||
"card-line2",
|
||||
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
||||
`<span class="activity-duration">4m</span>`,
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
const rail = h("span", "activity-rail");
|
||||
rail.dataset.activity = "active";
|
||||
rail.style.cssText = "position:static;display:inline-block;height:14px;";
|
||||
body.append(
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("activity-rail · active", rail),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||
);
|
||||
@@ -225,7 +232,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Session rows — nested by host, then directory",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
|
||||
);
|
||||
const list = h("div", "kit-tree");
|
||||
list.append(
|
||||
|
||||
+53
-8
@@ -24,12 +24,18 @@ import {
|
||||
} from "./fastForward.js";
|
||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||
import { openSpawnModal } from "./spawnModal.js";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
@@ -255,7 +261,29 @@ function requestOpenSpawn(): void {
|
||||
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
|
||||
// the old behaviour: both act on the session in front of you, so a split
|
||||
// beside it is the point.
|
||||
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
|
||||
// The session you were last on preselects its project: `n` then Enter spawns
|
||||
// another session there without touching the picker.
|
||||
const current = focusedSession();
|
||||
openSpawnModal({
|
||||
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
||||
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The session the operator was last on, if any. Shared by the plain spawn
|
||||
* modal seed and fast-forward's project pick: both should land on "where I
|
||||
* currently am". */
|
||||
function focusedSession(): Session | undefined {
|
||||
const focused = mv?.focusedSessionId() ?? null;
|
||||
return focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||
}
|
||||
|
||||
/** The focused session's project — its base repo for a worktree session, its
|
||||
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
|
||||
function focusedProjectCwd(): string | undefined {
|
||||
const s = focusedSession();
|
||||
if (!s) return undefined;
|
||||
return s.worktree ? s.worktree.base_repo : s.cwd;
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||
@@ -291,7 +319,7 @@ async function requestFastForward(): Promise<void> {
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const resolved = resolveFastForward(discovered, presets, name);
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -345,8 +373,27 @@ window.addEventListener("keydown", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Escape in the session list hands the keyboard back to the pane.
|
||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
});
|
||||
|
||||
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
||||
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that session's row silently ignored. */
|
||||
function focusSession(sessionId: string): void {
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
|
||||
focusSession((e as CustomEvent<string>).detail);
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
@@ -366,9 +413,7 @@ function dispatch(): void {
|
||||
switch (route.kind) {
|
||||
case "attach":
|
||||
// Deep-link: open/focus the session, then show the layout.
|
||||
showMultiView();
|
||||
mv.openSession(route.sessionId);
|
||||
updateShellRoute();
|
||||
focusSession(route.sessionId);
|
||||
return;
|
||||
case "not-found":
|
||||
shellEl.dataset.route = "notfound";
|
||||
|
||||
@@ -33,6 +33,10 @@ export function attach(id: Uuid): string {
|
||||
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
export function sessionFocus(id: Uuid): string {
|
||||
return `${session(id)}/focus`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
@@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => {
|
||||
expect(handle.hasListFocus()).toBe(true);
|
||||
});
|
||||
|
||||
it("Escape asks the shell for the pane back", () => {
|
||||
it("Tab asks the shell for the pane back", () => {
|
||||
const { el } = twoFolders();
|
||||
let asked = 0;
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
|
||||
press(tree(el), "Escape");
|
||||
press(tree(el), "Tab");
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
+14
-8
@@ -122,12 +122,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const form = document.createElement("form");
|
||||
form.className = "spawn-form";
|
||||
form.innerHTML = `
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
@@ -139,6 +133,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row cmd-tiles"></div>
|
||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
@@ -896,8 +896,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInput) {
|
||||
const top = visibleGroups(filterInput.value)[0];
|
||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||
// Resolve the filter to a project only when the current pick isn't one
|
||||
// of the matches: a filter typed past it means the operator moved on,
|
||||
// while a pick that still matches (including the seeded one the form
|
||||
// opens with) keeps its worktree choice.
|
||||
const visible = visibleGroups(filterInput.value);
|
||||
const stillMatches =
|
||||
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
|
||||
if (!stillMatches && visible[0]) selectProject(visible[0]);
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
@@ -68,9 +68,12 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
async function mount(
|
||||
projects: Project[],
|
||||
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
||||
): Promise<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
handle = createSpawnForm({ seed, onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
@@ -232,6 +235,24 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("submits in the seeded project rather than the freshest one", async () => {
|
||||
// What `n` then Enter must do: the modal opens parked on the project of
|
||||
// the session you were last on (OTHER here), even though MAESTRO's
|
||||
// worktree is the freshest row in the list.
|
||||
const form = await mount([MAESTRO, WT_A, OTHER], {
|
||||
cwd: OTHER.path,
|
||||
cwdRawVisible: false,
|
||||
});
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
|
||||
@@ -52,6 +52,16 @@ describe("spawn modal", () => {
|
||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
const form = modal()!.querySelector("form.spawn-form")!;
|
||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
@@ -59,6 +59,9 @@ export function openSpawnModal(opts: SpawnModalOptions): void {
|
||||
},
|
||||
});
|
||||
dialog.appendChild(form.el);
|
||||
// The project filter owns the keyboard on open: typing narrows the list and
|
||||
// Tab from here reaches the command tiles, matching the TUI's draft pane.
|
||||
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
|
||||
|
||||
function close(): void {
|
||||
if (closeCurrent === close) closeCurrent = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
@@ -43,3 +43,23 @@ describe("spawnSimilarSeed (#28)", () => {
|
||||
expect(seed.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnSimilarProjectSeed", () => {
|
||||
it("seeds the project alone, leaving command and worktree at their defaults", () => {
|
||||
const seed = spawnSimilarProjectSeed(session());
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
expect(seed.cwdRawVisible).toBe(false);
|
||||
expect(seed.command).toBeUndefined();
|
||||
expect(seed.worktreeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("seeds a worktree session's base repository", () => {
|
||||
const seed = spawnSimilarProjectSeed(
|
||||
session({
|
||||
cwd: "/repos/app-worktrees/feature",
|
||||
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
|
||||
}),
|
||||
);
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,17 @@ export function spawnSimilarSeed(s: Session): SpawnFormSeed {
|
||||
cwdRawVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The project half of a clone: the session's repository, nothing else.
|
||||
*
|
||||
* The plain spawn modal opens on this so the picker is already parked on the
|
||||
* project you were last looking at — one Enter spawns there — while the
|
||||
* command, worktree decision and name stay at the form's own defaults. Same
|
||||
* base-repository rule as `spawnSimilarSeed`. */
|
||||
export function spawnSimilarProjectSeed(s: Session): SpawnFormSeed {
|
||||
const wt = s.worktree ?? null;
|
||||
return {
|
||||
cwd: wt ? wt.base_repo : s.cwd,
|
||||
cwdRawVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
+447
-59
@@ -1,8 +1,12 @@
|
||||
/* =========================================================================
|
||||
maestro web — design system
|
||||
Register: product (a tool; design serves the task).
|
||||
Identity: precise, calm, alive. Monospace-as-UI; signal-green = "alive".
|
||||
Identity: precise, calm, alive. Monospace-as-UI; graphite chrome where the
|
||||
activity colours (green = alive) are the only hue on screen.
|
||||
Theme: dark (default) + light, all via OKLCH custom-property tokens.
|
||||
Palette: `data-palette` on <html> swaps the accent + base tint (see the
|
||||
"palettes" section at the end of the token area); graphite is the bare
|
||||
document.
|
||||
========================================================================= */
|
||||
|
||||
:root {
|
||||
@@ -63,24 +67,25 @@
|
||||
/* ---- color: dark (default) ---- */
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: oklch(0.178 0.012 256);
|
||||
--bg-grid: oklch(0.215 0.014 256);
|
||||
--surface: oklch(0.214 0.014 256);
|
||||
--surface-2: oklch(0.252 0.016 256);
|
||||
--surface-3: oklch(0.292 0.018 256);
|
||||
--border: oklch(0.318 0.018 256);
|
||||
--border-strong: oklch(0.4 0.022 256);
|
||||
--bg: oklch(0.172 0.006 256);
|
||||
--bg-grid: oklch(0.21 0.007 256);
|
||||
--surface: oklch(0.208 0.007 256);
|
||||
--surface-2: oklch(0.246 0.008 256);
|
||||
--surface-3: oklch(0.286 0.009 256);
|
||||
--border: oklch(0.312 0.01 256);
|
||||
--border-strong: oklch(0.395 0.012 256);
|
||||
|
||||
--ink: oklch(0.945 0.006 256);
|
||||
--muted: oklch(0.722 0.014 256);
|
||||
--faint: oklch(0.58 0.014 256);
|
||||
--ink: oklch(0.945 0.004 256);
|
||||
--muted: oklch(0.72 0.008 256);
|
||||
--faint: oklch(0.58 0.008 256);
|
||||
|
||||
/* signal green = "alive", primary action, focus, selection */
|
||||
--accent: oklch(0.83 0.16 152);
|
||||
--accent-strong: oklch(0.88 0.17 152);
|
||||
--accent-ink: oklch(0.2 0.03 256);
|
||||
--accent-tint: oklch(0.83 0.16 152 / 0.12);
|
||||
--accent-ring: oklch(0.83 0.16 152 / 0.55);
|
||||
/* graphite: the accent is ink itself — primary action, focus, selection.
|
||||
The activity colours below stay the only hue on screen. */
|
||||
--accent: oklch(0.92 0.005 256);
|
||||
--accent-strong: oklch(0.97 0.003 256);
|
||||
--accent-ink: oklch(0.2 0.012 256);
|
||||
--accent-tint: oklch(0.92 0.005 256 / 0.1);
|
||||
--accent-ring: oklch(0.92 0.005 256 / 0.5);
|
||||
|
||||
--live: oklch(0.84 0.17 152);
|
||||
--live-glow: oklch(0.84 0.17 152 / 0.55);
|
||||
@@ -92,30 +97,30 @@
|
||||
--shadow-1: 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
--shadow-2:
|
||||
0 12px 28px -8px oklch(0 0 0 / 0.6), 0 2px 8px oklch(0 0 0 / 0.4);
|
||||
--term-bg: oklch(0.158 0.012 256);
|
||||
--term-bg: oklch(0.155 0.006 256);
|
||||
}
|
||||
|
||||
:root[data-theme="light"],
|
||||
:root[data-theme="light"] body {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: oklch(0.985 0.003 256);
|
||||
--bg-grid: oklch(0.93 0.006 256);
|
||||
--bg: oklch(0.985 0.002 256);
|
||||
--bg-grid: oklch(0.93 0.004 256);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.965 0.004 256);
|
||||
--surface-3: oklch(0.93 0.006 256);
|
||||
--border: oklch(0.892 0.008 256);
|
||||
--border-strong: oklch(0.82 0.012 256);
|
||||
--surface-2: oklch(0.965 0.003 256);
|
||||
--surface-3: oklch(0.93 0.004 256);
|
||||
--border: oklch(0.892 0.005 256);
|
||||
--border-strong: oklch(0.82 0.008 256);
|
||||
|
||||
--ink: oklch(0.26 0.014 256);
|
||||
--muted: oklch(0.47 0.016 256);
|
||||
--faint: oklch(0.62 0.016 256);
|
||||
--ink: oklch(0.26 0.012 256);
|
||||
--muted: oklch(0.47 0.012 256);
|
||||
--faint: oklch(0.62 0.012 256);
|
||||
|
||||
--accent: oklch(0.6 0.15 151);
|
||||
--accent-strong: oklch(0.55 0.16 151);
|
||||
--accent-ink: oklch(0.99 0.01 152);
|
||||
--accent-tint: oklch(0.6 0.15 151 / 0.12);
|
||||
--accent-ring: oklch(0.6 0.15 151 / 0.5);
|
||||
--accent: oklch(0.27 0.01 256);
|
||||
--accent-strong: oklch(0.2 0.01 256);
|
||||
--accent-ink: oklch(0.985 0.002 256);
|
||||
--accent-tint: oklch(0.27 0.01 256 / 0.1);
|
||||
--accent-ring: oklch(0.27 0.01 256 / 0.45);
|
||||
|
||||
--live: oklch(0.6 0.16 151);
|
||||
--live-glow: oklch(0.6 0.16 151 / 0.45);
|
||||
@@ -136,23 +141,23 @@
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: oklch(0.985 0.003 256);
|
||||
--bg-grid: oklch(0.93 0.006 256);
|
||||
--bg: oklch(0.985 0.002 256);
|
||||
--bg-grid: oklch(0.93 0.004 256);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.965 0.004 256);
|
||||
--surface-3: oklch(0.93 0.006 256);
|
||||
--border: oklch(0.892 0.008 256);
|
||||
--border-strong: oklch(0.82 0.012 256);
|
||||
--surface-2: oklch(0.965 0.003 256);
|
||||
--surface-3: oklch(0.93 0.004 256);
|
||||
--border: oklch(0.892 0.005 256);
|
||||
--border-strong: oklch(0.82 0.008 256);
|
||||
|
||||
--ink: oklch(0.26 0.014 256);
|
||||
--muted: oklch(0.47 0.016 256);
|
||||
--faint: oklch(0.62 0.016 256);
|
||||
--ink: oklch(0.26 0.012 256);
|
||||
--muted: oklch(0.47 0.012 256);
|
||||
--faint: oklch(0.62 0.012 256);
|
||||
|
||||
--accent: oklch(0.6 0.15 151);
|
||||
--accent-strong: oklch(0.55 0.16 151);
|
||||
--accent-ink: oklch(0.99 0.01 152);
|
||||
--accent-tint: oklch(0.6 0.15 151 / 0.12);
|
||||
--accent-ring: oklch(0.6 0.15 151 / 0.5);
|
||||
--accent: oklch(0.27 0.01 256);
|
||||
--accent-strong: oklch(0.2 0.01 256);
|
||||
--accent-ink: oklch(0.985 0.002 256);
|
||||
--accent-tint: oklch(0.27 0.01 256 / 0.1);
|
||||
--accent-ring: oklch(0.27 0.01 256 / 0.45);
|
||||
|
||||
--live: oklch(0.6 0.16 151);
|
||||
--live-glow: oklch(0.6 0.16 151 / 0.45);
|
||||
@@ -169,6 +174,311 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- palettes ----
|
||||
`data-palette` on <html> recolours the accent + base tint on top of the
|
||||
light/dark machinery above. Graphite (the default) is the bare document.
|
||||
Each palette ships a dark block, a pinned-light block, and a system-light
|
||||
block; `--live`/`--danger`/`--warn` stay semantic and are not redefined
|
||||
(except amber-phosphor, whose accent would collide with the stock warn). */
|
||||
|
||||
/* ice-cyan: signal-cyan accent on the slate base. */
|
||||
:root[data-palette="ice-cyan"] {
|
||||
--bg: oklch(0.178 0.012 256);
|
||||
--bg-grid: oklch(0.215 0.014 256);
|
||||
--surface: oklch(0.214 0.014 256);
|
||||
--surface-2: oklch(0.252 0.016 256);
|
||||
--surface-3: oklch(0.292 0.018 256);
|
||||
--border: oklch(0.318 0.018 256);
|
||||
--border-strong: oklch(0.4 0.022 256);
|
||||
--ink: oklch(0.945 0.006 256);
|
||||
--muted: oklch(0.722 0.014 256);
|
||||
--faint: oklch(0.58 0.014 256);
|
||||
--accent: oklch(0.85 0.115 205);
|
||||
--accent-strong: oklch(0.89 0.12 205);
|
||||
--accent-ink: oklch(0.24 0.03 230);
|
||||
--accent-tint: oklch(0.85 0.115 205 / 0.12);
|
||||
--accent-ring: oklch(0.85 0.115 205 / 0.55);
|
||||
--term-bg: oklch(0.158 0.012 256);
|
||||
}
|
||||
:root[data-palette="ice-cyan"][data-theme="light"],
|
||||
:root[data-palette="ice-cyan"][data-theme="light"] body {
|
||||
--bg: oklch(0.985 0.003 256);
|
||||
--bg-grid: oklch(0.93 0.006 256);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.965 0.004 256);
|
||||
--surface-3: oklch(0.93 0.006 256);
|
||||
--border: oklch(0.892 0.008 256);
|
||||
--border-strong: oklch(0.82 0.012 256);
|
||||
--ink: oklch(0.26 0.014 256);
|
||||
--muted: oklch(0.47 0.016 256);
|
||||
--faint: oklch(0.62 0.016 256);
|
||||
--accent: oklch(0.52 0.1 220);
|
||||
--accent-strong: oklch(0.47 0.11 220);
|
||||
--accent-ink: oklch(0.99 0.005 220);
|
||||
--accent-tint: oklch(0.52 0.1 220 / 0.12);
|
||||
--accent-ring: oklch(0.52 0.1 220 / 0.5);
|
||||
--term-bg: oklch(0.165 0.012 256);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-palette="ice-cyan"]:not([data-theme]) {
|
||||
--bg: oklch(0.985 0.003 256);
|
||||
--bg-grid: oklch(0.93 0.006 256);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.965 0.004 256);
|
||||
--surface-3: oklch(0.93 0.006 256);
|
||||
--border: oklch(0.892 0.008 256);
|
||||
--border-strong: oklch(0.82 0.012 256);
|
||||
--ink: oklch(0.26 0.014 256);
|
||||
--muted: oklch(0.47 0.016 256);
|
||||
--faint: oklch(0.62 0.016 256);
|
||||
--accent: oklch(0.52 0.1 220);
|
||||
--accent-strong: oklch(0.47 0.11 220);
|
||||
--accent-ink: oklch(0.99 0.005 220);
|
||||
--accent-tint: oklch(0.52 0.1 220 / 0.12);
|
||||
--accent-ring: oklch(0.52 0.1 220 / 0.5);
|
||||
--term-bg: oklch(0.165 0.012 256);
|
||||
}
|
||||
}
|
||||
/* periwinkle: indigo base, blue-violet accent. */
|
||||
:root[data-palette="periwinkle"] {
|
||||
--bg: oklch(0.175 0.018 278);
|
||||
--bg-grid: oklch(0.212 0.021 278);
|
||||
--surface: oklch(0.212 0.021 278);
|
||||
--surface-2: oklch(0.25 0.024 278);
|
||||
--surface-3: oklch(0.29 0.026 278);
|
||||
--border: oklch(0.318 0.026 278);
|
||||
--border-strong: oklch(0.4 0.028 278);
|
||||
--ink: oklch(0.93 0.012 278);
|
||||
--muted: oklch(0.71 0.02 278);
|
||||
--faint: oklch(0.57 0.022 278);
|
||||
--accent: oklch(0.75 0.125 275);
|
||||
--accent-strong: oklch(0.79 0.13 275);
|
||||
--accent-ink: oklch(0.18 0.04 278);
|
||||
--accent-tint: oklch(0.75 0.125 275 / 0.14);
|
||||
--accent-ring: oklch(0.75 0.125 275 / 0.55);
|
||||
--term-bg: oklch(0.155 0.018 278);
|
||||
}
|
||||
:root[data-palette="periwinkle"][data-theme="light"],
|
||||
:root[data-palette="periwinkle"][data-theme="light"] body {
|
||||
--bg: oklch(0.982 0.004 278);
|
||||
--bg-grid: oklch(0.93 0.008 278);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.96 0.006 278);
|
||||
--surface-3: oklch(0.93 0.008 278);
|
||||
--border: oklch(0.885 0.01 278);
|
||||
--border-strong: oklch(0.82 0.014 278);
|
||||
--ink: oklch(0.27 0.02 278);
|
||||
--muted: oklch(0.47 0.02 278);
|
||||
--faint: oklch(0.62 0.02 278);
|
||||
--accent: oklch(0.5 0.17 272);
|
||||
--accent-strong: oklch(0.45 0.17 272);
|
||||
--accent-ink: oklch(0.99 0.005 272);
|
||||
--accent-tint: oklch(0.5 0.17 272 / 0.12);
|
||||
--accent-ring: oklch(0.5 0.17 272 / 0.5);
|
||||
--term-bg: oklch(0.165 0.018 278);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-palette="periwinkle"]:not([data-theme]) {
|
||||
--bg: oklch(0.982 0.004 278);
|
||||
--bg-grid: oklch(0.93 0.008 278);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.96 0.006 278);
|
||||
--surface-3: oklch(0.93 0.008 278);
|
||||
--border: oklch(0.885 0.01 278);
|
||||
--border-strong: oklch(0.82 0.014 278);
|
||||
--ink: oklch(0.27 0.02 278);
|
||||
--muted: oklch(0.47 0.02 278);
|
||||
--faint: oklch(0.62 0.02 278);
|
||||
--accent: oklch(0.5 0.17 272);
|
||||
--accent-strong: oklch(0.45 0.17 272);
|
||||
--accent-ink: oklch(0.99 0.005 272);
|
||||
--accent-tint: oklch(0.5 0.17 272 / 0.12);
|
||||
--accent-ring: oklch(0.5 0.17 272 / 0.5);
|
||||
--term-bg: oklch(0.165 0.018 278);
|
||||
}
|
||||
}
|
||||
|
||||
/* amber-phosphor: CRT amber on warm graphite. `--warn` shifts orange so the
|
||||
awaiting colour stays distinct from the accent. */
|
||||
:root[data-palette="amber-phosphor"] {
|
||||
--bg: oklch(0.178 0.008 70);
|
||||
--bg-grid: oklch(0.214 0.01 70);
|
||||
--surface: oklch(0.214 0.01 70);
|
||||
--surface-2: oklch(0.252 0.012 70);
|
||||
--surface-3: oklch(0.292 0.013 70);
|
||||
--border: oklch(0.318 0.014 70);
|
||||
--border-strong: oklch(0.4 0.016 70);
|
||||
--ink: oklch(0.94 0.008 78);
|
||||
--muted: oklch(0.72 0.014 75);
|
||||
--faint: oklch(0.58 0.014 75);
|
||||
--accent: oklch(0.83 0.14 78);
|
||||
--accent-strong: oklch(0.87 0.145 78);
|
||||
--accent-ink: oklch(0.25 0.05 78);
|
||||
--accent-tint: oklch(0.83 0.14 78 / 0.13);
|
||||
--accent-ring: oklch(0.83 0.14 78 / 0.55);
|
||||
--warn: oklch(0.76 0.15 55);
|
||||
--term-bg: oklch(0.158 0.008 70);
|
||||
}
|
||||
:root[data-palette="amber-phosphor"][data-theme="light"],
|
||||
:root[data-palette="amber-phosphor"][data-theme="light"] body {
|
||||
--bg: oklch(0.983 0.004 78);
|
||||
--bg-grid: oklch(0.93 0.007 78);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.962 0.005 78);
|
||||
--surface-3: oklch(0.93 0.007 78);
|
||||
--border: oklch(0.888 0.01 78);
|
||||
--border-strong: oklch(0.82 0.012 78);
|
||||
--ink: oklch(0.27 0.015 70);
|
||||
--muted: oklch(0.47 0.016 70);
|
||||
--faint: oklch(0.62 0.016 70);
|
||||
--accent: oklch(0.6 0.13 70);
|
||||
--accent-strong: oklch(0.55 0.13 70);
|
||||
--accent-ink: oklch(0.99 0.01 78);
|
||||
--accent-tint: oklch(0.6 0.13 70 / 0.12);
|
||||
--accent-ring: oklch(0.6 0.13 70 / 0.5);
|
||||
--warn: oklch(0.58 0.14 50);
|
||||
--term-bg: oklch(0.165 0.008 70);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-palette="amber-phosphor"]:not([data-theme]) {
|
||||
--bg: oklch(0.983 0.004 78);
|
||||
--bg-grid: oklch(0.93 0.007 78);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.962 0.005 78);
|
||||
--surface-3: oklch(0.93 0.007 78);
|
||||
--border: oklch(0.888 0.01 78);
|
||||
--border-strong: oklch(0.82 0.012 78);
|
||||
--ink: oklch(0.27 0.015 70);
|
||||
--muted: oklch(0.47 0.016 70);
|
||||
--faint: oklch(0.62 0.016 70);
|
||||
--accent: oklch(0.6 0.13 70);
|
||||
--accent-strong: oklch(0.55 0.13 70);
|
||||
--accent-ink: oklch(0.99 0.01 78);
|
||||
--accent-tint: oklch(0.6 0.13 70 / 0.12);
|
||||
--accent-ring: oklch(0.6 0.13 70 / 0.5);
|
||||
--warn: oklch(0.58 0.14 50);
|
||||
--term-bg: oklch(0.165 0.008 70);
|
||||
}
|
||||
}
|
||||
|
||||
/* ember: warm orange-red accent. */
|
||||
:root[data-palette="ember"] {
|
||||
--bg: oklch(0.18 0.009 50);
|
||||
--bg-grid: oklch(0.216 0.011 50);
|
||||
--surface: oklch(0.216 0.011 50);
|
||||
--surface-2: oklch(0.254 0.013 50);
|
||||
--surface-3: oklch(0.294 0.014 50);
|
||||
--border: oklch(0.32 0.015 50);
|
||||
--border-strong: oklch(0.4 0.017 50);
|
||||
--ink: oklch(0.94 0.007 50);
|
||||
--muted: oklch(0.72 0.013 50);
|
||||
--faint: oklch(0.58 0.013 50);
|
||||
--accent: oklch(0.74 0.15 45);
|
||||
--accent-strong: oklch(0.78 0.155 45);
|
||||
--accent-ink: oklch(0.2 0.04 45);
|
||||
--accent-tint: oklch(0.74 0.15 45 / 0.14);
|
||||
--accent-ring: oklch(0.74 0.15 45 / 0.55);
|
||||
--term-bg: oklch(0.158 0.009 50);
|
||||
}
|
||||
:root[data-palette="ember"][data-theme="light"],
|
||||
:root[data-palette="ember"][data-theme="light"] body {
|
||||
--bg: oklch(0.983 0.004 50);
|
||||
--bg-grid: oklch(0.93 0.006 50);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.962 0.005 50);
|
||||
--surface-3: oklch(0.93 0.006 50);
|
||||
--border: oklch(0.888 0.009 50);
|
||||
--border-strong: oklch(0.82 0.012 50);
|
||||
--ink: oklch(0.27 0.014 50);
|
||||
--muted: oklch(0.47 0.015 50);
|
||||
--faint: oklch(0.62 0.015 50);
|
||||
--accent: oklch(0.58 0.17 40);
|
||||
--accent-strong: oklch(0.53 0.17 40);
|
||||
--accent-ink: oklch(0.99 0.008 40);
|
||||
--accent-tint: oklch(0.58 0.17 40 / 0.11);
|
||||
--accent-ring: oklch(0.58 0.17 40 / 0.5);
|
||||
--term-bg: oklch(0.165 0.009 50);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-palette="ember"]:not([data-theme]) {
|
||||
--bg: oklch(0.983 0.004 50);
|
||||
--bg-grid: oklch(0.93 0.006 50);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.962 0.005 50);
|
||||
--surface-3: oklch(0.93 0.006 50);
|
||||
--border: oklch(0.888 0.009 50);
|
||||
--border-strong: oklch(0.82 0.012 50);
|
||||
--ink: oklch(0.27 0.014 50);
|
||||
--muted: oklch(0.47 0.015 50);
|
||||
--faint: oklch(0.62 0.015 50);
|
||||
--accent: oklch(0.58 0.17 40);
|
||||
--accent-strong: oklch(0.53 0.17 40);
|
||||
--accent-ink: oklch(0.99 0.008 40);
|
||||
--accent-tint: oklch(0.58 0.17 40 / 0.11);
|
||||
--accent-ring: oklch(0.58 0.17 40 / 0.5);
|
||||
--term-bg: oklch(0.165 0.009 50);
|
||||
}
|
||||
}
|
||||
|
||||
/* orchid: muted violet accent on a faintly violet base. */
|
||||
:root[data-palette="orchid"] {
|
||||
--bg: oklch(0.177 0.014 305);
|
||||
--bg-grid: oklch(0.213 0.017 305);
|
||||
--surface: oklch(0.213 0.017 305);
|
||||
--surface-2: oklch(0.251 0.02 305);
|
||||
--surface-3: oklch(0.291 0.021 305);
|
||||
--border: oklch(0.318 0.022 305);
|
||||
--border-strong: oklch(0.4 0.024 305);
|
||||
--ink: oklch(0.935 0.01 305);
|
||||
--muted: oklch(0.715 0.018 305);
|
||||
--faint: oklch(0.575 0.018 305);
|
||||
--accent: oklch(0.78 0.13 310);
|
||||
--accent-strong: oklch(0.82 0.135 310);
|
||||
--accent-ink: oklch(0.22 0.05 310);
|
||||
--accent-tint: oklch(0.78 0.13 310 / 0.14);
|
||||
--accent-ring: oklch(0.78 0.13 310 / 0.55);
|
||||
--term-bg: oklch(0.155 0.014 305);
|
||||
}
|
||||
:root[data-palette="orchid"][data-theme="light"],
|
||||
:root[data-palette="orchid"][data-theme="light"] body {
|
||||
--bg: oklch(0.983 0.004 305);
|
||||
--bg-grid: oklch(0.93 0.007 305);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.961 0.005 305);
|
||||
--surface-3: oklch(0.93 0.007 305);
|
||||
--border: oklch(0.886 0.01 305);
|
||||
--border-strong: oklch(0.82 0.013 305);
|
||||
--ink: oklch(0.27 0.018 305);
|
||||
--muted: oklch(0.47 0.018 305);
|
||||
--faint: oklch(0.62 0.018 305);
|
||||
--accent: oklch(0.5 0.17 308);
|
||||
--accent-strong: oklch(0.45 0.17 308);
|
||||
--accent-ink: oklch(0.99 0.005 308);
|
||||
--accent-tint: oklch(0.5 0.17 308 / 0.11);
|
||||
--accent-ring: oklch(0.5 0.17 308 / 0.5);
|
||||
--term-bg: oklch(0.165 0.014 305);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-palette="orchid"]:not([data-theme]) {
|
||||
--bg: oklch(0.983 0.004 305);
|
||||
--bg-grid: oklch(0.93 0.007 305);
|
||||
--surface: oklch(1 0 0);
|
||||
--surface-2: oklch(0.961 0.005 305);
|
||||
--surface-3: oklch(0.93 0.007 305);
|
||||
--border: oklch(0.886 0.01 305);
|
||||
--border-strong: oklch(0.82 0.013 305);
|
||||
--ink: oklch(0.27 0.018 305);
|
||||
--muted: oklch(0.47 0.018 305);
|
||||
--faint: oklch(0.62 0.018 305);
|
||||
--accent: oklch(0.5 0.17 308);
|
||||
--accent-strong: oklch(0.45 0.17 308);
|
||||
--accent-ink: oklch(0.99 0.005 308);
|
||||
--accent-tint: oklch(0.5 0.17 308 / 0.11);
|
||||
--accent-ring: oklch(0.5 0.17 308 / 0.5);
|
||||
--term-bg: oklch(0.165 0.014 305);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- reset / base ---------- */
|
||||
|
||||
* {
|
||||
@@ -435,6 +745,35 @@ textarea {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Palette picker: a ghost-button-shaped <select>. */
|
||||
.palette-select {
|
||||
appearance: none;
|
||||
height: var(--control-h);
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-2);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
cursor: pointer;
|
||||
max-width: 9ch;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.palette-select:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.palette-select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- daemon status dot ---------- */
|
||||
|
||||
/* Colour is the link state; the tooltip names the release the daemon is
|
||||
@@ -1054,12 +1393,32 @@ input.leaf-rename:focus {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
|
||||
pinned to the row's own left edge, full height, square corners — see
|
||||
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
|
||||
states that need attention (DESIGN.md's "living signals" rule). */
|
||||
.activity-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
background: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.activity-rail[data-activity="active"] {
|
||||
color: var(--live);
|
||||
}
|
||||
.activity-rail[data-activity="idle"] {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
.activity-rail[data-activity="awaiting-input"] {
|
||||
color: var(--warn);
|
||||
animation: blink 1.2s steps(1) infinite;
|
||||
}
|
||||
.activity-rail[data-activity="error"] {
|
||||
color: var(--danger);
|
||||
animation: rail-flash 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.activity-duration {
|
||||
@@ -1499,20 +1858,29 @@ input.leaf-rename:focus {
|
||||
|
||||
/* ---------- mobile special-key bar (Termux-style) ---------- */
|
||||
|
||||
/* Two compact rows (Termux layout) in a 5-column grid the 10 keys flow into:
|
||||
Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T on
|
||||
the right (Home/End fill the top corners). Layout is always defined; only
|
||||
*visibility* is gated: hidden on precise-pointer devices (a physical keyboard
|
||||
already has these keys), shown on touch where the soft keyboard omits them.
|
||||
The shell tracks the visual viewport (see `#app`), so it lands above it. */
|
||||
/* Two compact rows (Termux layout) in an 8-column grid the 16 keys flow into:
|
||||
Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T in
|
||||
the middle (Home/End fill the top corners), then PgUp/PgDn and the shell
|
||||
symbols. Columns never shrink below a thumb's width — on a narrow phone the
|
||||
bar scrolls sideways instead. Layout is always defined; only *visibility* is
|
||||
gated: hidden on precise-pointer devices (a physical keyboard already has
|
||||
these keys), shown on touch where the soft keyboard omits them. The shell
|
||||
tracks the visual viewport (see `#app`), so it lands above it. */
|
||||
.attach-keybar {
|
||||
display: none;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(8, minmax(2.5rem, 1fr));
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.attach-keybar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shown on touch only while *this* terminal is focused (`keybar-active`, toggled
|
||||
@@ -2245,6 +2613,25 @@ input.leaf-rename:focus {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2294,7 +2681,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
.brand-dot,
|
||||
[data-status="running"] .leaf-status::before,
|
||||
.tally-live::before {
|
||||
.tally-live::before,
|
||||
.activity-rail {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPaletteSelect,
|
||||
initTheme,
|
||||
PALETTES,
|
||||
resolvedPalette,
|
||||
setPalette,
|
||||
} from "./theme.js";
|
||||
|
||||
// happy-dom's vitest environment ships no localStorage global; back it with a
|
||||
// plain in-memory map so persistence is observable.
|
||||
let store = new Map<string, string>();
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("palette selection", () => {
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
delete document.documentElement.dataset.palette;
|
||||
delete document.documentElement.dataset.theme;
|
||||
});
|
||||
|
||||
it("defaults to graphite with no data-palette attribute", () => {
|
||||
initTheme();
|
||||
expect(resolvedPalette()).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setPalette stamps data-palette and persists", () => {
|
||||
setPalette("ember");
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
expect(resolvedPalette()).toBe("ember");
|
||||
|
||||
// A fresh boot re-applies the stored palette.
|
||||
delete document.documentElement.dataset.palette;
|
||||
initTheme();
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
});
|
||||
|
||||
it("selecting graphite removes the attribute again", () => {
|
||||
setPalette("orchid");
|
||||
setPalette("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
initTheme();
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores an unknown stored palette", () => {
|
||||
localStorage.setItem("maestro-palette", "glorb");
|
||||
initTheme();
|
||||
expect(resolvedPalette()).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the picker lists every palette and applies the chosen one", () => {
|
||||
const select = createPaletteSelect();
|
||||
expect([...select.options].map((o) => o.value)).toEqual([...PALETTES]);
|
||||
expect(select.value).toBe("graphite");
|
||||
|
||||
select.value = "periwinkle";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
expect(document.documentElement.dataset.palette).toBe("periwinkle");
|
||||
expect(localStorage.getItem("maestro-palette")).toBe("periwinkle");
|
||||
});
|
||||
});
|
||||
+64
-1
@@ -1,10 +1,26 @@
|
||||
// Theme: dark (default) + light, persisted. When the user hasn't pinned a
|
||||
// theme we follow the OS via CSS `prefers-color-scheme`; pinning sets an
|
||||
// explicit `data-theme` on <html> that overrides the media query.
|
||||
//
|
||||
// Palette: orthogonal to light/dark. Each palette ships both a dark and a
|
||||
// light token set in styles.css; `data-palette` on <html> selects it, and
|
||||
// graphite — the default — is the bare document with no attribute.
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const KEY = "maestro-theme";
|
||||
|
||||
/** Every `data-palette` value, in picker order. Graphite is the default. */
|
||||
export const PALETTES = [
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
] as const;
|
||||
export type Palette = (typeof PALETTES)[number];
|
||||
const PALETTE_KEY = "maestro-palette";
|
||||
|
||||
function stored(): Theme | null {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
@@ -21,18 +37,46 @@ export function resolvedTheme(): Theme {
|
||||
return stored() ?? systemTheme();
|
||||
}
|
||||
|
||||
/** Apply a pinned theme at boot (no-op when following the system). */
|
||||
function storedPalette(): Palette | null {
|
||||
const v = localStorage.getItem(PALETTE_KEY);
|
||||
return (PALETTES as readonly string[]).includes(v ?? "")
|
||||
? (v as Palette)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The palette in force (stored or the graphite default). */
|
||||
export function resolvedPalette(): Palette {
|
||||
return document.documentElement.dataset.palette as Palette | undefined
|
||||
?? storedPalette()
|
||||
?? "graphite";
|
||||
}
|
||||
|
||||
/** Pin a palette. Graphite is the bare document, so it clears the attribute. */
|
||||
export function setPalette(p: Palette): void {
|
||||
localStorage.setItem(PALETTE_KEY, p);
|
||||
if (p === "graphite") delete document.documentElement.dataset.palette;
|
||||
else document.documentElement.dataset.palette = p;
|
||||
}
|
||||
|
||||
/** Apply the pinned theme + palette at boot (no-ops when unpinned). */
|
||||
export function initTheme(): void {
|
||||
// Dev-only `?theme=light|dark` override for visual review; stripped in prod.
|
||||
if (import.meta.env.DEV) {
|
||||
const q = new URLSearchParams(location.search).get("theme");
|
||||
if (q === "light" || q === "dark") {
|
||||
document.documentElement.dataset.theme = q;
|
||||
applyStoredPalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pinned = stored();
|
||||
if (pinned) document.documentElement.dataset.theme = pinned;
|
||||
applyStoredPalette();
|
||||
}
|
||||
|
||||
function applyStoredPalette(): void {
|
||||
const p = storedPalette();
|
||||
if (p && p !== "graphite") document.documentElement.dataset.palette = p;
|
||||
}
|
||||
|
||||
function setTheme(t: Theme): void {
|
||||
@@ -58,3 +102,22 @@ export function createThemeToggle(): HTMLButtonElement {
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** A footer dropdown that picks the colour palette. */
|
||||
export function createPaletteSelect(): HTMLSelectElement {
|
||||
const select = document.createElement("select");
|
||||
select.className = "palette-select";
|
||||
select.setAttribute("aria-label", "Colour palette");
|
||||
select.title = "Colour palette";
|
||||
for (const p of PALETTES) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = resolvedPalette();
|
||||
select.addEventListener("change", () => {
|
||||
setPalette(select.value as Palette);
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user