diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..14f9b80 --- /dev/null +++ b/.config/nextest.toml @@ -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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fda3cdb..4980bc0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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') }} diff --git a/crates/maestro-daemon/src/activity.rs b/crates/maestro-daemon/src/activity.rs index 5ece6ed..4d02d2b 100644 --- a/crates/maestro-daemon/src/activity.rs +++ b/crates/maestro-daemon/src/activity.rs @@ -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, + 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); + } } diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index b766810..03189aa 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -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; @@ -46,6 +47,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), @@ -309,6 +311,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, + Path(id): Path, + Json(body): Json, +) -> 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() } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 2e3fded..ecfea91 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -76,6 +76,15 @@ pub struct SessionRecord { /// Daemon-local bookkeeping; never rides the wire. #[cfg(feature = "haiku-autoname")] pub naming: 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, + /// 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)] @@ -465,6 +474,8 @@ impl SessionManager { settled: false, #[cfg(feature = "haiku-autoname")] naming: false, + focused_since: None, + awaiting_suppressed: false, }, ); } @@ -670,6 +681,8 @@ impl SessionManager { settled: false, #[cfg(feature = "haiku-autoname")] naming: false, + focused_since: None, + awaiting_suppressed: false, }, ); let _ = self @@ -821,6 +834,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(()) + } } #[cfg(feature = "haiku-autoname")] @@ -1062,10 +1086,19 @@ async fn run_activity_pass(inner: &Arc) { // #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. diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 01ceb7f..69ed30b 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -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 { self.get(routes::INFO, &[]).await } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 3281a06..09c74f7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -642,6 +642,17 @@ pub struct SessionPromptRequest { pub prompt: String, } +/// 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; diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index d7990a7..a044563 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -60,6 +60,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}") @@ -72,6 +78,7 @@ pub mod patterns { pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt"; + pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus"; pub const ATTACH: &str = "/api/attach/:id"; } @@ -191,6 +198,10 @@ mod tests { session_prompt(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/prompt" ); + assert_eq!( + session_focus(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -221,6 +232,10 @@ mod tests { patterns::SESSION_PROMPT.trim_end_matches(":id/prompt"), 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()) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 15fadd2..55a64ee 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -83,6 +83,12 @@ pub struct App { region: RegionMap, last_sent_size: HashMap, 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, } impl std::fmt::Debug for App { @@ -163,6 +169,7 @@ impl App { region: RegionMap::default(), last_sent_size: HashMap::new(), clicks: ClickTracker::default(), + focused_session: None, } } @@ -176,6 +183,7 @@ impl App { } let mut events = EventStream::new(); loop { + self.sync_focus().await; self.redraw(terminal)?; tokio::select! { @@ -605,7 +613,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 { + self.state.active_session().and_then(|sid| { self.catalog .snapshot() .sessions @@ -615,11 +635,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, @@ -750,9 +766,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 { @@ -813,6 +831,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()); @@ -821,6 +840,7 @@ impl App { info: info.ok(), projects: projects.ok(), names, + focused_cwd, }); }); } @@ -830,6 +850,7 @@ impl App { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, ) { // Keep the home dir: without it the pre-filled draft renders // unabbreviated paths and "Other directory…" seeds `/` instead of @@ -843,8 +864,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; @@ -912,6 +938,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>, @@ -1139,6 +1186,7 @@ enum DraftUpdate { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, }, } @@ -1619,6 +1667,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>>; + + let posted: PostedFocus = Arc::default(); + let seen = posted.clone(); + async fn set_focus( + axum::extract::State(seen): axum::extract::State, + Path(id): Path, + Json(body): Json, + ) -> 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] diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index db75b8c..b031068 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -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(); diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index f8df0d0..287ddca 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -30,16 +30,25 @@ pub struct FastForwardDefaults { /// Resolve the fast-forward defaults from the daemon's answers. `handle` 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], handle: Option, now: DateTime, + 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()) @@ -157,7 +166,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 handle"); } + #[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); @@ -253,6 +322,7 @@ mod tests { &[preset("claude", "claude")], Some("amber-falcon".into()), Utc::now(), + None, ); let mut draft = DraftPane::default(); resolved.apply_to_draft(&mut draft); @@ -267,7 +337,13 @@ mod tests { #[test] fn an_unresolved_handle_still_arrives_from_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-handle".into()]); @@ -278,7 +354,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"); diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 5200407..46f1b5b 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -230,6 +230,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 diff --git a/web/src/api.ts b/web/src/api.ts index 50f6e20..a5b63a8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -91,6 +91,22 @@ export async function killSession(id: Uuid, force = false): Promise { ); } +/** 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 { + 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 { diff --git a/web/src/attach.ts b/web/src/attach.ts index ec7d236..6ce6de1 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -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. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 73e3d5e..8bfecec 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -37,14 +37,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 — @@ -193,6 +205,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(); @@ -558,7 +573,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(); }); diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts new file mode 100644 index 0000000..27622de --- /dev/null +++ b/web/src/connections.test.ts @@ -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(); + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index fc8b6e0..abc7b87 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -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); } diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 9d1d6e5..4366c28 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -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"); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 6a47f44..5f83611 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -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; diff --git a/web/src/help.ts b/web/src/help.ts index e6a0565..6b38721 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -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" }, ], }, { diff --git a/web/src/main.ts b/web/src/main.ts index 23ac046..f635094 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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).detail; @@ -298,7 +319,7 @@ async function requestFastForward(): Promise { } 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).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"; diff --git a/web/src/routes.ts b/web/src/routes.ts index 69ab42c..090b388 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -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)}`; diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts index 32464bf..ccbe9da 100644 --- a/web/src/sidebarNav.test.ts +++ b/web/src/sidebarNav.test.ts @@ -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); });