Merge remote-tracking branch 'origin/main' into oboe
# Conflicts: # web/src/catalog.ts
This commit is contained in:
@@ -69,7 +69,7 @@ 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);
|
||||
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
|
||||
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
||||
@@ -161,7 +161,7 @@ mod tests {
|
||||
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));
|
||||
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_millis(200));
|
||||
assert_eq!(
|
||||
apply_focus_suppression(
|
||||
ActivityState::AwaitingInput,
|
||||
|
||||
@@ -52,10 +52,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// because Normal mode only exists while the sidebar is focused, so
|
||||
// nothing forwards it to a pty.
|
||||
("tab", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
||||
// `n`/`N`, the same pair the web client binds.
|
||||
("n", "OpenSpawnModal"),
|
||||
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
|
||||
// thing with every default resolved and no form.
|
||||
("S", "FastForwardSpawn"),
|
||||
("N", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
("r", "RenameHighlighted"),
|
||||
// Focus
|
||||
@@ -220,11 +221,11 @@ mod tests {
|
||||
Some(Action::AttachHighlighted),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
cfg.keymap.resolve(Mode::Normal, "n"),
|
||||
Some(Action::OpenSpawnModal),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
@@ -311,18 +312,18 @@ mod tests {
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||
/// Regression for #70: the previous canonical form of a real Shift+N
|
||||
/// keypress was `"shift-N"`, which the default `"N"` binding never
|
||||
/// matched. Drive the actual crossterm event, not the binding string,
|
||||
/// so the dead-key bug can't come back unnoticed.
|
||||
#[test]
|
||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
|
||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::input::{canonicalise, Region};
|
||||
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
|
||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -597,7 +597,8 @@ mod tests {
|
||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||
/// make every uppercase binding (the default `N`) unreachable from the
|
||||
/// keyboard (#70).
|
||||
#[test]
|
||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||
let k = InputKey::Char {
|
||||
|
||||
+2
-1
@@ -141,7 +141,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
|
||||
[tui.keymap.normal]
|
||||
"i" = "EnterInsert"
|
||||
"s" = "OpenSpawnModal"
|
||||
"n" = "OpenSpawnModal"
|
||||
"N" = "FastForwardSpawn"
|
||||
"x" = "KillHighlighted"
|
||||
"r" = "RenameHighlighted"
|
||||
"?" = "ShowHelp"
|
||||
|
||||
+19
-1
@@ -234,7 +234,7 @@ the agent, and answering 204 would hide it.
|
||||
|
||||
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
|
||||
**continuously focused for 1 second**: 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
|
||||
@@ -274,3 +274,21 @@ than jumping to the end of the list.
|
||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||
`ctrl-w h`.
|
||||
|
||||
Everything else is the same key on both clients:
|
||||
|
||||
| key | action |
|
||||
|---------|---------------------------------------------|
|
||||
| `j`/`k` | move the highlight (arrows too) |
|
||||
| `Enter` | open the session, or expand the folder |
|
||||
| `Tab` | hand the keyboard to the pane |
|
||||
| `c` | collapse or expand the highlighted folder |
|
||||
| `r` | rename the highlighted session |
|
||||
| `x` | kill the highlighted session |
|
||||
| `n` | open the spawn form |
|
||||
| `N` | spawn with every default resolved |
|
||||
| `?` | show the shortcut overlay |
|
||||
|
||||
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
in both help overlays.
|
||||
|
||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
+28
-1
@@ -596,6 +596,26 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
||||
|
||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||
const row = rowFor(navKey);
|
||||
const id = row?.dataset.sessionId;
|
||||
if (!row || !id) return undefined;
|
||||
const session = store.snapshot().sessions.get(id);
|
||||
return session ? { row, session } : undefined;
|
||||
};
|
||||
|
||||
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||
* the key does nothing rather than toggling some other folder. */
|
||||
const toggleHighlightedFolder = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||
row.click();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
treeHost.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
// The inline rename field lives inside a row and owns its own keys.
|
||||
@@ -604,7 +624,14 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||
else if (e.key === "Enter") activateNav();
|
||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||
else return;
|
||||
else if (e.key === "c") toggleHighlightedFolder();
|
||||
else if (e.key === "x") {
|
||||
const hit = highlightedSession();
|
||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||
} else if (e.key === "r") {
|
||||
const hit = highlightedSession();
|
||||
if (hit) beginRename(hit.row, hit.session);
|
||||
} else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
||||
{ keys: ["j"], description: "Highlight the next session" },
|
||||
{ keys: ["k"], description: "Highlight the previous session" },
|
||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
|
||||
{ keys: ["r"], description: "Rename the highlighted session" },
|
||||
{ keys: ["x"], description: "Kill the highlighted session" },
|
||||
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||
],
|
||||
|
||||
@@ -120,6 +120,43 @@ describe("sidebar keyboard navigation", () => {
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it("c collapses and expands the highlighted folder", () => {
|
||||
const { el } = twoFolders();
|
||||
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
||||
header.click(); // collapse /one, so the walk stops on its header
|
||||
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe("folder:one");
|
||||
press(tree(el), "c");
|
||||
// Expanded: the card it was hiding is back in the walk.
|
||||
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("r renames the highlighted session in place", () => {
|
||||
const { el } = twoFolders();
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "r");
|
||||
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(field).not.toBeNull();
|
||||
// Escape settles the rename, so the module-level "a row is being renamed"
|
||||
// flag doesn't leak into the next test.
|
||||
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
|
||||
it("x kills the highlighted session", async () => {
|
||||
const { el } = twoFolders();
|
||||
const killed: string[] = [];
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof window.fetch;
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "x");
|
||||
await Promise.resolve();
|
||||
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the keyboard alone while a row is being renamed", () => {
|
||||
const { el } = twoFolders();
|
||||
press(tree(el), "j");
|
||||
|
||||
Reference in New Issue
Block a user