Compare commits

...
14 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
24 changed files with 866 additions and 86 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 }} group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs: jobs:
rust: rust:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -30,23 +34,36 @@ jobs:
--component rustfmt --component clippy --component rustfmt --component clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" 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: | run: |
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
corepack enable corepack enable
corepack prepare pnpm@9.15.4 --activate 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 - name: Cache cargo
uses: actions/cache@v4 uses: Swatinem/rust-cache@v2
with: with:
path: | save-if: ${{ github.ref == 'refs/heads/main' }}
~/.cargo/registry
~/.cargo/git # nextest runs test *binaries* in parallel, which plain `cargo test`
target # does not — this suite is 20 integration binaries that mostly sit
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} # waiting on tmux and git. Thread count is capped in
restore-keys: | # `.config/nextest.toml`; see the comment there.
${{ runner.os }}-cargo- - name: Install cargo-nextest
run: |
curl -sSfL https://get.nexte.st/latest/linux \
| tar zxf - -C "$HOME/.cargo/bin"
- name: fmt - name: fmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
@@ -55,7 +72,7 @@ jobs:
run: cargo clippy --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: test - name: test
run: cargo test --all run: cargo nextest run --all --profile ci
# NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon / # NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon /
# tui). Verify each subset compiles and its tests pass on its own, and # 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 for f in cli daemon tui; do
echo "::group::maestro --no-default-features --features $f" echo "::group::maestro --no-default-features --features $f"
cargo build -p 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::" echo "::endgroup::"
done done
echo "::group::maestro with no features must fail to compile" echo "::group::maestro with no features must fail to compile"
@@ -81,12 +99,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Node.js + pnpm - uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable pnpm
run: | 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 enable
corepack prepare pnpm@9.15.4 --activate corepack prepare pnpm@9.15.4 --activate
@@ -110,25 +128,88 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 # 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 # 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 .#…` # config goes in first. Flakes are off by default and `nix build .#…`
# needs them. Written again afterwards in case the installer replaced it. # 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 - name: Install nix
run: | run: |
set -euo pipefail
mkdir -p /etc/nix mkdir -p /etc/nix
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf 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 profile=/nix/var/nix/profiles/per-user/root/profile
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf 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" 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 # The installer does not enable flakes, so `nix build .#…` needs the
# features passed explicitly — a local machine that has them in # features passed explicitly — a local machine that has them in
# nix.conf will build fine while CI fails without this. # 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: | run: |
nix --extra-experimental-features "nix-command flakes" build \ nix --extra-experimental-features "nix-command flakes" build \
.#webFrontend \ .#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 \
.#maestro-web \ .#maestro-web \
--no-link --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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -118,4 +156,99 @@ mod tests {
Some("short") 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_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -45,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::SESSION_FOCUS, post(set_focus))
.route( .route(
routes::patterns::SESSION, routes::patterns::SESSION,
delete(kill_session).patch(rename_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 { fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_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 /// (#115), so this is the observable that says its outcomes are safe to
/// assert on. Daemon-local bookkeeping; never rides the wire. /// assert on. Daemon-local bookkeeping; never rides the wire.
pub settled: bool, 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)] #[derive(Debug, thiserror::Error)]
@@ -456,6 +465,8 @@ impl SessionManager {
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
focused_since: None,
awaiting_suppressed: false,
}, },
); );
} }
@@ -659,6 +670,8 @@ impl SessionManager {
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
focused_since: None,
awaiting_suppressed: false,
}, },
); );
let _ = self let _ = self
@@ -799,6 +812,17 @@ impl SessionManager {
} }
Ok(()) 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 /// 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 // #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is // nothing here may overwrite it; only an unreported session is
// decided by inference. // decided by inference.
let activity = match &rec.reported_activity { let raw_activity = match &rec.reported_activity {
Some(report) => report.state, Some(report) => report.state,
None => crate::activity::decide(idle, awaiting), 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()); let foreground = Some(pane.command.clone());
// The pane's live cwd, drives the dir-based session tree; empty means // The pane's live cwd, drives the dir-based session tree; empty means
// tmux reported none. // tmux reported none.
+13
View File
@@ -17,6 +17,7 @@ use crate::routes::{self, UrlError};
use crate::{ use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
SetFocusRequest,
}; };
/// Time budget for establishing a TCP connection to the daemon. /// Time budget for establishing a TCP connection to the daemon.
@@ -166,6 +167,18 @@ impl DaemonClient {
Ok(()) 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> { pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await self.get(routes::INFO, &[]).await
} }
+11
View File
@@ -573,6 +573,17 @@ fn submit_default() -> bool {
true 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 /// 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. /// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64; 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") 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. /// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String { pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}") format!("{API_PREFIX}/attach/{id}")
@@ -65,6 +71,7 @@ pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; 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"; pub const ATTACH: &str = "/api/attach/:id";
} }
@@ -180,6 +187,10 @@ mod tests {
session_input(id()), session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input" "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
); );
assert_eq!(
session_focus(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
);
assert_eq!( assert_eq!(
attach(id()), attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -206,6 +217,10 @@ mod tests {
patterns::SESSION_INPUT.trim_end_matches(":id/input"), patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string()) 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!( assert_eq!(
patterns::ATTACH.trim_end_matches(":id"), patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string()) attach(id()).trim_end_matches(&id().to_string())
+121 -9
View File
@@ -83,6 +83,12 @@ pub struct App {
region: RegionMap, region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>, last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker, 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 { impl std::fmt::Debug for App {
@@ -165,6 +171,7 @@ impl App {
region: RegionMap::default(), region: RegionMap::default(),
last_sent_size: HashMap::new(), last_sent_size: HashMap::new(),
clicks: ClickTracker::default(), clicks: ClickTracker::default(),
focused_session: None,
} }
} }
@@ -178,6 +185,7 @@ impl App {
} }
let mut events = EventStream::new(); let mut events = EventStream::new();
loop { loop {
self.sync_focus().await;
self.redraw(terminal)?; self.redraw(terminal)?;
tokio::select! { tokio::select! {
@@ -608,7 +616,19 @@ impl App {
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same /// daemon never adopts an existing worktree (docs/WORKTREES.md), same
/// rule as web/src/spawnSimilar.ts. /// rule as web/src/spawnSimilar.ts.
fn open_spawn_modal(&mut self) { 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 self.catalog
.snapshot() .snapshot()
.sessions .sessions
@@ -618,11 +638,7 @@ impl App {
Some(wt) => wt.base_repo.clone(), Some(wt) => wt.base_repo.clone(),
None => s.cwd.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, /// Close the form and drop its half-filled state — reopening starts clean,
@@ -767,9 +783,11 @@ impl App {
info, info,
projects, projects,
names, names,
focused_cwd,
} = update } = update
{ {
self.fast_forward_spawn(info, projects, names).await; self.fast_forward_spawn(info, projects, names, focused_cwd)
.await;
return; return;
} }
let want = match &update { let want = match &update {
@@ -836,6 +854,7 @@ impl App {
/// `/api/projects` walks all of `$HOME`, and pressing the key must not /// `/api/projects` walks all of `$HOME`, and pressing the key must not
/// freeze keys, frames and attached panes until it returns. /// freeze keys, frames and attached panes until it returns.
fn spawn_fast_forward(&self) { fn spawn_fast_forward(&self) {
let focused_cwd = self.focused_seed_cwd();
let (daemon, http, base, tx) = self.loader_handles(); let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move { tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
@@ -844,6 +863,7 @@ impl App {
info: info.ok(), info: info.ok(),
projects: projects.ok(), projects: projects.ok(),
names, names,
focused_cwd,
}); });
}); });
} }
@@ -853,6 +873,7 @@ impl App {
info: Option<maestro_protocol::DaemonInfo>, info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>, projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>, names: Vec<String>,
focused_cwd: Option<PathBuf>,
) { ) {
// Keep the home dir: without it the pre-filled draft renders // Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of // unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -866,8 +887,13 @@ impl App {
_ => Vec::new(), _ => Vec::new(),
}; };
let discovered = projects.unwrap_or_default(); let discovered = projects.unwrap_or_default();
let resolved = let resolved = crate::fast_forward::resolve(
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); &discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
if let Some(req) = resolved.build_request() { if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await; self.submit_spawn(req).await;
return; 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( fn redraw(
&mut self, &mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -1166,6 +1213,7 @@ enum DraftUpdate {
info: Option<maestro_protocol::DaemonInfo>, info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>, projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>, names: Vec<String>,
focused_cwd: Option<PathBuf>,
}, },
} }
@@ -1645,6 +1693,70 @@ mod tests {
assert_eq!(req["worktree"]["create_branch"], json!(true)); 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 /// No projects known yet: nothing can resolve, so the keystroke opens a
/// spawn form instead of guessing (#67). /// spawn form instead of guessing (#67).
#[tokio::test] #[tokio::test]
+28 -4
View File
@@ -48,6 +48,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("c", "ToggleSidebarGroup"), ("c", "ToggleSidebarGroup"),
// Catalog actions // Catalog actions
("enter", "AttachHighlighted"), ("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"), ("s", "OpenSpawnModal"),
// Fast-forward spawn (#67): the spawn key's neighbour does the same // Fast-forward spawn (#67): the spawn key's neighbour does the same
// thing with every default resolved and no form. // thing with every default resolved and no form.
@@ -63,14 +67,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("ctrl-w b", "ToggleSidebar"), ("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"), ("?", "ShowHelp"),
("ctrl-w q", "Quit"), ("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
("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
]; ];
/// Default Insert-mode bindings. Empty by default — the only Insert binding /// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so /// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
/// rebinding the leader rebinds its Insert escape automatically. /// escape automatically; `esc` is a second, fixed way back to the sidebar.
const DEFAULT_INSERT: &[(&str, &str)] = &[]; const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
impl TuiConfig { impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui` /// 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] #[test]
fn default_leader_is_ctrl_a_and_escapes_insert() { fn default_leader_is_ctrl_a_and_escapes_insert() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); 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 /// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the /// `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( pub fn resolve(
projects: &[Project], projects: &[Project],
presets: &[SpawnPreset], presets: &[SpawnPreset],
name: Option<String>, name: Option<String>,
now: DateTime<Utc>, now: DateTime<Utc>,
focused_cwd: Option<&std::path::Path>,
) -> FastForwardDefaults { ) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now) let project = focused_cwd
.into_iter() .and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
.next(); .or_else(|| {
crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next()
});
let command = project let command = project
.as_ref() .as_ref()
.and_then(|p| p.last_command.clone()) .and_then(|p| p.last_command.clone())
@@ -155,7 +164,13 @@ mod tests {
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
]; ];
let presets = vec![preset("aider", "aider"), preset("custom", "")]; 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!( assert_eq!(
r.project.as_ref().unwrap().path, r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh") PathBuf::from("/repos/fresh")
@@ -176,6 +191,46 @@ mod tests {
assert!(wt.create_branch, "fresh branch named after the session"); 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] #[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() { fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
@@ -184,6 +239,7 @@ mod tests {
&[preset("zsh", "zsh")], &[preset("zsh", "zsh")],
Some("s".into()), Some("s".into()),
Utc::now(), Utc::now(),
None,
); );
let wt = r.build_request().unwrap().worktree.unwrap(); let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); 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() { fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)]; let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")]; 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")); assert_eq!(r.command.as_deref(), Some("claude"));
} }
@@ -201,7 +257,7 @@ mod tests {
fn an_empty_last_command_is_no_command_at_all() { fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))]; let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")]; 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_eq!(r.command, None);
assert!(r.build_request().is_none()); assert!(r.build_request().is_none());
} }
@@ -213,6 +269,7 @@ mod tests {
&[preset("claude", "claude")], &[preset("claude", "claude")],
Some("n".into()), Some("n".into()),
Utc::now(), Utc::now(),
None,
); );
assert_eq!(r.project, None); assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name. // No project → no directory → no request, even with command + name.
@@ -222,14 +279,26 @@ mod tests {
#[test] #[test]
fn a_missing_generated_name_downgrades_to_a_draft() { fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))]; 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"); assert!(r.build_request().is_none(), "no name → no immediate spawn");
} }
#[test] #[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
@@ -251,6 +320,7 @@ mod tests {
&[preset("claude", "claude")], &[preset("claude", "claude")],
Some("amber-falcon".into()), Some("amber-falcon".into()),
Utc::now(), Utc::now(),
None,
); );
let mut draft = DraftPane::default(); let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft); named.apply_to_draft(&mut draft);
@@ -263,7 +333,13 @@ mod tests {
#[test] #[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() { fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]); draft.seed_names(vec!["late-name".into()]);
@@ -273,7 +349,13 @@ mod tests {
#[test] #[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() { fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d"); assert_eq!(draft.command(), "nvim -d");
+35 -7
View File
@@ -51,13 +51,15 @@ 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 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. 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 positioned inside the row's own In the **web client** the rail is a 5px bar pinned to the row's own left
indent gutter — it shifts outward with nesting depth exactly as the gutter edge, square corners, spanning the full card height edge to edge — it
does — spanning the full card height. It carries motion, reserved for states shifts outward with nesting depth along with the row's own padding, since
that need attention (DESIGN.md's "living signals" rule): a slow breathing it is positioned relative to the row. It carries motion only for states that
opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while need attention (DESIGN.md's "living signals" rule): a hard blink while
`Error`. `Idle` is solid and still, on purpose. `prefers-reduced-motion` `AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
leaves every state as a solid bar. 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 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 palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
@@ -209,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 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. 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 ## Keyboard walk
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients `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, /** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
* `~`, relative, or absolute — the daemon resolves it against its home dir. */ * `~`, relative, or absolute — the daemon resolves it against its home dir. */
export async function listDir(path: string): Promise<DirListing> { 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 "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -134,6 +135,15 @@ export function createAttachConnection(
term.loadAddon(fit); term.loadAddon(fit);
term.open(body); 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 // 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: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
+23 -10
View File
@@ -36,14 +36,26 @@ export function requestOpenSpawn(): void {
} }
/** Ask the shell to hand the keyboard back to the focused pane's terminal — /** 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. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; 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 /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; 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 /** 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 * 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 — * cards under it are one row away anyway. A *collapsed* folder still counts —
@@ -68,13 +80,11 @@ const STATE_WORDS: Record<string, string> = {
error: "Error", error: "Error",
}; };
/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent /** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* gutter, so it shifts outward with nesting depth exactly as that gutter * full height. The row's own padding (`renderNode`) shifts outward with
* does. `depth * 16 + 8` is the same expression `renderNode` uses for the * nesting depth, carrying the rail with it. */
* row's own padding — the rail sits just inside it. */ function activityRailMarkup(slug: string, stateWord: string): string {
function activityRailMarkup(slug: string, stateWord: string, depth: number): string { return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
const left = depth * 16 + 2;
return `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
} }
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
@@ -172,7 +182,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
); );
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = ` row.innerHTML = `
${activityRailMarkup(slug, stateWord, depth)} ${activityRailMarkup(slug, stateWord)}
<span class="card-line1"> <span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span> <span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions"> <span class="leaf-actions">
@@ -194,6 +204,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation(); ev.stopPropagation();
void requestKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
});
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -559,7 +572,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
if (e.key === "j" || e.key === "ArrowDown") moveNav(1); if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav(); 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; else return;
e.preventDefault(); 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 AttachConnection,
type AttachInfo, type AttachInfo,
} from "./attach.js"; } from "./attach.js";
import { setSessionFocus } from "./api.js";
import type { Uuid } from "./types.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); 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 { setFocus(id: Uuid | null): void {
if (id === this.focusedId) return; if (id === this.focusedId) return;
const previous = this.focusedId;
this.focusedId = id; this.focusedId = id;
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
this.opts.onFocusChange?.(id); 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", () => { it("falls back to the first non-custom preset when there is no last command", () => {
const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
expect(res.command).toBe("claude"); expect(res.command).toBe("claude");
+9 -4
View File
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
name?: string; name?: string;
} }
/** The defaults: most recent project (fresh-first, same ordering the picker /** The defaults: the currently focused session's project when it's still a
* shows), its last command falling back to the first non-custom preset, and * known project, else the most recent one (fresh-first, same ordering the
* a freshly generated name when the daemon supplied one. */ * 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( export function resolveFastForward(
projects: Project[], projects: Project[],
presets: SpawnPreset[], presets: SpawnPreset[],
name?: string, name?: string,
focusedCwd?: string,
): FastForwardResolution { ): 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 lastCommand = project?.last_command?.trim();
const command = const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined; 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: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" }, { keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, { 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" },
], ],
}, },
{ {
+46 -8
View File
@@ -29,7 +29,13 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js"; import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.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 { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js"; import { installViewportSizing } from "./viewport.js";
@@ -257,13 +263,28 @@ function requestOpenSpawn(): void {
// beside it is the point. // beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns // The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker. // another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null; const current = focusedSession();
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
openSpawnModal({ openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined, seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }), 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(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
@@ -298,7 +319,7 @@ async function requestFastForward(): Promise<void> {
} catch { } catch {
/* keep fallbacks */ /* keep fallbacks */
} }
const resolved = resolveFastForward(discovered, presets, name); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved); const req = buildFastForwardRequest(resolved);
if (req) { if (req) {
try { 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_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 = type Route =
| { kind: "catalog" } | { kind: "catalog" }
@@ -373,9 +413,7 @@ function dispatch(): void {
switch (route.kind) { switch (route.kind) {
case "attach": case "attach":
// Deep-link: open/focus the session, then show the layout. // Deep-link: open/focus the session, then show the layout.
showMultiView(); focusSession(route.sessionId);
mv.openSession(route.sessionId);
updateShellRoute();
return; return;
case "not-found": case "not-found":
shellEl.dataset.route = "notfound"; shellEl.dataset.route = "notfound";
+4
View File
@@ -33,6 +33,10 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; 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. */ /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string { export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+2 -2
View File
@@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => {
expect(handle.hasListFocus()).toBe(true); 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(); const { el } = twoFolders();
let asked = 0; let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1)); document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Escape"); press(tree(el), "Tab");
expect(asked).toBe(1); expect(asked).toBe(1);
}); });
+5 -7
View File
@@ -1393,23 +1393,21 @@ input.leaf-rename:focus {
color: var(--danger); color: var(--danger);
} }
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits /* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
inside the row's own indent gutter (`.tree-leaf` is already positioned), pinned to the row's own left edge, full height, square corners — see
at a `left` set inline per nesting depth — see `activityRailMarkup` in
catalog.ts. Idle stays solid and still on purpose: motion is reserved for catalog.ts. Idle stays solid and still on purpose: motion is reserved for
states that need attention (DESIGN.md's "living signals" rule). */ states that need attention (DESIGN.md's "living signals" rule). */
.activity-rail { .activity-rail {
position: absolute; position: absolute;
top: 4px; top: 0;
bottom: 4px; bottom: 0;
left: 0;
width: 5px; width: 5px;
border-radius: 2px;
background: currentColor; background: currentColor;
} }
.activity-rail[data-activity="active"] { .activity-rail[data-activity="active"] {
color: var(--live); color: var(--live);
animation: pulse 2.4s var(--ease-out) infinite;
} }
.activity-rail[data-activity="idle"] { .activity-rail[data-activity="idle"] {
color: var(--border-strong); color: var(--border-strong);