Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7d5fb505 | ||
|
|
66f386b87b | ||
|
|
e18ad716a0 | ||
|
|
f7e7a77912 | ||
|
|
d4c72577b9 | ||
|
|
aed9a5eec0 | ||
|
|
f84199d292 | ||
|
|
64d5829037 |
@@ -0,0 +1,20 @@
|
|||||||
|
# The integration suites drive real tmux servers and real git repos, and most
|
||||||
|
# of their wall time is spent waiting on those subprocesses rather than on the
|
||||||
|
# CPU. nextest runs test binaries in parallel — which plain `cargo test` does
|
||||||
|
# not — but at its `num-cpus` default the tmux-backed tests starve each other
|
||||||
|
# and the tighter `wait_for_async` deadlines (5s) start timing out. Capping the
|
||||||
|
# concurrency keeps the cross-binary win without the contention.
|
||||||
|
|
||||||
|
[profile.ci]
|
||||||
|
test-threads = 4
|
||||||
|
|
||||||
|
# Anything past this is reported as slow while it keeps running, so a genuine
|
||||||
|
# hang is visible in the log instead of only showing up as a job timeout.
|
||||||
|
slow-timeout = { period = "30s", terminate-after = 8 }
|
||||||
|
|
||||||
|
failure-output = "immediate-final"
|
||||||
|
final-status-level = "slow"
|
||||||
|
|
||||||
|
# nextest does not run doctests. There are none today (every ``` fence in the
|
||||||
|
# crates is `toml` or `text`), so nothing is lost; adding a real doctest means
|
||||||
|
# adding `cargo test --doc` to CI alongside this.
|
||||||
+102
-21
@@ -11,6 +11,10 @@ concurrency:
|
|||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
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') }}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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! {
|
||||||
@@ -953,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>>,
|
||||||
@@ -1664,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]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -211,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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+6
-2
@@ -36,10 +36,14 @@ 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";
|
||||||
@@ -568,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||||
|
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||||
|
|
||||||
|
function mockFetch() {
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConnectionPool.setFocus", () => {
|
||||||
|
it("reports the new session focused, none unfocused, the first time", () => {
|
||||||
|
const fetchMock = mockFetch();
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
|
||||||
|
pool.setFocus(A);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unfocuses the previous session and focuses the new one on a switch", () => {
|
||||||
|
const fetchMock = mockFetch();
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
pool.setFocus(A);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
pool.setFocus(B);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
|
||||||
|
url,
|
||||||
|
JSON.parse((init as RequestInit).body as string),
|
||||||
|
]);
|
||||||
|
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
|
||||||
|
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unfocused when focus clears to null", () => {
|
||||||
|
const fetchMock = mockFetch();
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
pool.setFocus(A);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
pool.setFocus(null);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the focused session doesn't change", () => {
|
||||||
|
const fetchMock = mockFetch();
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
pool.setFocus(A);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
pool.setFocus(A);
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a fetch rejection break the pool", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||||
|
);
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
|
||||||
|
expect(() => pool.setFocus(A)).not.toThrow();
|
||||||
|
// Let the rejected promise's `.catch` settle before the test ends.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AttachConnection,
|
type 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-1
@@ -31,6 +31,7 @@ import { createMultiView, type MultiView } from "./multiview.js";
|
|||||||
import { loadLayout, saveLayout } from "./persistence.js";
|
import { loadLayout, saveLayout } from "./persistence.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
|
FOCUS_SIDEBAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
SPAWN_SIMILAR_EVENT,
|
SPAWN_SIMILAR_EVENT,
|
||||||
@@ -372,8 +373,13 @@ 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
|
/** 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
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user