Compare commits

..
19 Commits
Author SHA1 Message Date
Miguel Palhas fa7d5fb505 Merge remote-tracking branch 'origin/main' into activity
ci / web (push) Successful in 39s
ci / rust (push) Successful in 3m45s
ci / flake (push) Successful in 4m16s
2026-09-02 18:08:00 +01:00
Miguel Palhas 66f386b87b Merge remote-tracking branch 'origin/main' into activity 2026-09-02 18:03:40 +01:00
Miguel Palhas e18ad716a0 Merge remote-tracking branch 'origin/main' into shortcuts
ci / rust (push) Successful in 5m14s
ci / web (push) Successful in 1m2s
ci / flake (push) Successful in 5m23s
2026-09-02 18:03:33 +01:00
Miguel Palhas f7e7a77912 feat(maestro): clear AwaitingInput glow once a pane has been focused
A session's rail glows AwaitingInput even after the operator has already
looked at it. Clients now report pane focus (POST /:id/focus); the daemon
clears the glow to Idle after 5s of continuous focus and keeps it cleared
until the session does new work, so a fresh Active cycle is required to
glow again.
2026-09-02 18:03:26 +01:00
Miguel PalhasandClaude Sonnet 5 d4c72577b9 feat(maestro): swap web session-list/pane focus keys to Tab/Esc
Tab now moves the keyboard from the session list to the pane (was
Escape); Escape moves it back from the pane to the list, intercepted via
xterm's attachCustomKeyEventHandler so it never reaches the shell/app.
Matches the TUI's `tab`/`esc` pair; Ctrl+Q was ruled out since browsers
reserve it and never deliver it to page JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:58:28 +01:00
Miguel PalhasandClaude Sonnet 5 aed9a5eec0 feat(maestro): AoE-style bare q/tab/esc keybinds in the TUI
Normal mode only exists while the sidebar is focused (Insert mode always
pairs with Focus::Pane), so bare `q` and `tab` there never reach a pty:
`q` quits, `tab` focuses the highlighted pane (same as Enter). `esc` is
added as a second, fixed way out of Insert alongside the leader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:58:24 +01:00
naps62-yolo f84199d292 Merge pull request 'ci(maestro): cache nix store, trim CI hot paths' (#132) from ci into main
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
2026-09-02 17:53:55 +01:00
Miguel PalhasandClaude Opus 5 64d5829037 ci(maestro): cache nix store, trim CI hot paths
ci / web (pull_request) Successful in 40s
ci / flake (pull_request) Successful in 3m58s
ci / rust (pull_request) Successful in 4m36s
The `flake` job was the pipeline's critical path at ~9 minutes, all of it
one cold `nix build`: with an empty /nix every run, it recompiled the whole
release closure under `lto = "thin"` + `codegen-units = 1`.

- Cache /nix across runs, keyed on the lockfiles the closure derives from,
  so crane's deps-only derivation survives and only maestro's own crates
  rebuild. Saved from pushes only, so a PR cannot write a partial store.
- Build only `.#webFrontend` on PRs — that is the step that guards
  pnpmDeps.hash. The release binaries stay on main.
- Replace the hand-rolled `target/` cache with Swatinem/rust-cache, which
  prunes to the current lockfile instead of accumulating stale artifacts
  behind a prefix restore-key. ~60s per run went into moving that entry.
- Run tests under cargo-nextest, which parallelises across the 20
  integration binaries. Capped at 4 threads in .config/nextest.toml: the
  tmux-backed tests starve each other at the num-cpus default and trip
  their 5s deadlines.
- actions/setup-node instead of a second apt-get pass via nodesource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 17:48:32 +01:00
Miguel Palhas 2ad2858fd5 Merge remote-tracking branch 'origin/main' into dorian
ci / web (push) Successful in 2m7s
ci / rust (push) Successful in 5m32s
ci / flake (push) Successful in 10m35s
2026-09-02 17:39:39 +01:00
Miguel Palhas fa2a26faa9 Merge remote-tracking branch 'origin/main' into dorian 2026-09-02 17:37:17 +01:00
Miguel Palhas b5feb3c39a fix(maestro): re-focus a sidebar session already open in a split
Sidebar rows only navigated via location.hash + hashchange. Moving
pane focus by split, drag, or direct pane click never touched the
hash, so a later click back onto a session whose row href matched the
stale hash fired no hashchange and was silently ignored — the pane
had to be clicked directly to regain focus.

Row clicks now also dispatch a session-select event handled the same
way as a hash deep-link, independent of whether the hash text changes.
2026-09-02 17:37:15 +01:00
Miguel Palhas e12baa23c6 fix(maestro): fast-forward spawn prefers the focused session's project
ci / flake (push) Successful in 10m50s
ci / web (push) Successful in 1m6s
ci / rust (push) Successful in 5m17s
N picked the globally freshest project by git-mtime, ignoring the session
the operator was actually on. Plain n's spawn form already seeds from the
focused session (spawnSimilarProjectSeed); fast-forward now matches that,
falling back to recency only when nothing is focused.
2026-09-02 17:36:11 +01:00
Miguel Palhas 63191c7be1 Merge remote-tracking branch 'origin/main' into rubato
ci / web (push) Successful in 1m50s
ci / rust (push) Successful in 5m7s
ci / flake (push) Successful in 8m45s
2026-09-02 17:19:26 +01:00
Miguel Palhas acb84b91ef fix(maestro): pin activity rail to left edge, drop active-state pulse
Rail now spans full row height flush to the left edge with square
corners, instead of a depth-shifted inset bar. Active state stays
solid green since colour alone already signals it; blink/flash stay
for awaiting-input/error.
2026-09-02 17:18:00 +01:00
Miguel PalhasandClaude Sonnet 5 c8ab4a531e fix(maestro): tool icon left of diff stat on sidebar meta line
ci / flake (push) Successful in 9m17s
ci / web (push) Successful in 1m9s
ci / rust (push) Successful in 3m55s
Meta line fragment order was command last, after the blocked-work diff
stat, so the tool icon sat right of the diff count instead of left.
Reorder to duration/detail/command/diff-stat in both web and TUI
sidebars per docs/SIDEBAR.md, and update the contract doc + TUI tests
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:17:44 +01:00
naps62 21fc20b30a Merge pull request 'docs(maestro): replace the sidebar's activity dot with a rail' (#130) from indicators into main
ci / web (push) Successful in 3m40s
ci / rust (push) Successful in 5m49s
ci / flake (push) Successful in 6m12s
Reviewed-on: #130
2026-09-02 17:00:58 +01:00
Miguel Palhas abbd15a8b2 feat(maestro): TUI sidebar activity rail
ci / web (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 5m47s
ci / flake (pull_request) Successful in 9m14s
Leads the existing indent gutter on both the name row and the meta
row with a one-column block glyph in the state's palette role,
replacing the meta line's inline dot. Folder headers gain a matching
blank column so labels stay aligned under session names.
2026-09-02 16:47:51 +01:00
Miguel Palhas 1d18e21420 feat(maestro): web sidebar activity rail
Replaces the inline activity dot with a coloured bar positioned inside
each row's own indent gutter, per the updated docs/SIDEBAR.md contract.
Active breathes, awaiting-input blinks, error flashes; idle stays
solid. prefers-reduced-motion leaves every state static.
2026-09-02 16:47:47 +01:00
Miguel Palhas 38a65e585f docs(maestro): replace the sidebar's activity dot with a rail
The inline dot competed with the meta line's other fragments and left
the indent gutter unused. A rail moves the state signal into that
gutter instead, on both clients.
2026-09-02 16:47:42 +01:00
28 changed files with 1012 additions and 134 deletions
+20
View File
@@ -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
View File
@@ -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') }}
+133
View File
@@ -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);
}
}
+25 -1
View File
@@ -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()
}
+34 -1
View File
@@ -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.
+13
View File
@@ -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
}
+11
View File
@@ -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;
+15
View File
@@ -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())
+121 -9
View File
@@ -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! {
@@ -608,7 +616,19 @@ impl App {
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
/// rule as web/src/spawnSimilar.ts.
fn open_spawn_modal(&mut self) {
let seed_cwd = self.state.active_session().and_then(|sid| {
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
@@ -618,11 +638,7 @@ impl App {
Some(wt) => wt.base_repo.clone(),
None => s.cwd.clone(),
})
});
let mut draft = DraftPane::default();
draft.seed_cwd = seed_cwd;
self.draft = Some(draft);
self.state.open_spawn_modal();
})
}
/// Close the form and drop its half-filled state — reopening starts clean,
@@ -767,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 {
@@ -836,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());
@@ -844,6 +863,7 @@ impl App {
info: info.ok(),
projects: projects.ok(),
names,
focused_cwd,
});
});
}
@@ -853,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
@@ -866,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;
@@ -935,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>>,
@@ -1166,6 +1213,7 @@ enum DraftUpdate {
info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
},
}
@@ -1645,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]
+28 -4
View File
@@ -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`
@@ -222,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();
+93 -11
View File
@@ -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");
+2 -2
View File
@@ -13,8 +13,8 @@
//!
//! 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`] — graphite, no accent hue — is the default.
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
+48 -32
View File
@@ -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]
+62 -12
View File
@@ -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
+16
View File
@@ -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> {
+10
View File
@@ -3,6 +3,7 @@ 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, tildeSeq } from "./keys.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.
+9 -7
View File
@@ -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);
});
+31 -9
View File
@@ -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();
@@ -550,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();
});
+87
View File
@@ -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();
});
});
+8
View File
@@ -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);
}
+24
View File
@@ -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");
+9 -4
View File
@@ -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
View File
@@ -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" },
],
},
{
+11 -4
View File
@@ -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;
}
@@ -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(
+46 -8
View File
@@ -29,7 +29,13 @@ 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";
@@ -257,13 +263,28 @@ function requestOpenSpawn(): void {
// beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null;
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
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) => {
const s = (ev as CustomEvent<Session>).detail;
@@ -298,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 {
@@ -352,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" }
@@ -373,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";
+4
View File
@@ -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)}`;
+2 -2
View File
@@ -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);
});
+46 -6
View File
@@ -1393,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 {
@@ -2593,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) {
@@ -2642,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;
}
}