From 5fbc4438c85f555ed73c058bf337e5e2dba3671e Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Wed, 7 Oct 2026 19:38:56 -0600 Subject: [PATCH 01/14] Add OpenSpec proposal ui-animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decorative ASCII animations for the startup splash (frame trace, identity-line reveal, title sweep inside the 800 ms hold, then a panel wipe), the F9 menu bar and every pop-up menu (sweep, top-down unfold, highlight settle), and every modal dialog (100 ms iris). All motion is render-only interpolation of the reducer's Tick clock against open-time stamps, so core::update stays pure, input is never blocked, and insta snapshots stay deterministic. Disabled via `animations = false` or `--noanimations` (flag wins); no new theme roles, so user theme files keep validating. Supersedes design doc §4.10's no-animation rule for decorative chrome only; loading feedback stays static. Adds the new ui-animations capability and deltas for startup-splash and pulldown-menus. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/ui-animations/.openspec.yaml | 2 + openspec/changes/ui-animations/design.md | 81 +++++++++++ openspec/changes/ui-animations/proposal.md | 35 +++++ .../specs/pulldown-menus/spec.md | 21 +++ .../specs/startup-splash/spec.md | 60 ++++++++ .../ui-animations/specs/ui-animations/spec.md | 129 ++++++++++++++++++ openspec/changes/ui-animations/tasks.md | 38 ++++++ 7 files changed, 366 insertions(+) create mode 100644 openspec/changes/ui-animations/.openspec.yaml create mode 100644 openspec/changes/ui-animations/design.md create mode 100644 openspec/changes/ui-animations/proposal.md create mode 100644 openspec/changes/ui-animations/specs/pulldown-menus/spec.md create mode 100644 openspec/changes/ui-animations/specs/startup-splash/spec.md create mode 100644 openspec/changes/ui-animations/specs/ui-animations/spec.md create mode 100644 openspec/changes/ui-animations/tasks.md diff --git a/openspec/changes/ui-animations/.openspec.yaml b/openspec/changes/ui-animations/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/ui-animations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/ui-animations/design.md b/openspec/changes/ui-animations/design.md new file mode 100644 index 0000000..9b6c6f7 --- /dev/null +++ b/openspec/changes/ui-animations/design.md @@ -0,0 +1,81 @@ +# Design: ui-animations + +## Context + +The TUI event loop (`filecommand-tui/src/app.rs`) polls crossterm with a 33 ms timeout; every timeout delivers `Command::Tick(now)` from the injected monotonic `Clock`, the reducer stores it in `State::clock_ms`, and the loop redraws. The startup splash already uses this heartbeat for its 800 ms minimum hold (`UiPhase::Splash { started_at_ms }`). All rendering is a pure function of `State` through `views::render`, every overlay is positioned by `dialogs::overlay_rect`, and every color comes from a theme role. User theme files in `themes/*.toml` are validated against the full role list — a missing role is a hard error. Snapshot tests build `State` directly via `State::empty` and pin the clock. + +## Goals / Non-Goals + +**Goals:** + +- Decorative motion on the splash, the panel reveal after it, the F9 bar and all pop-up menus, and every modal dialog — Norton Commander glyphs and colors only. +- Zero impact on responsiveness: state transitions are instant; animations are interpolation of elapsed time at render; nothing waits for an animation to finish. +- Deterministic and snapshot-testable; existing snapshots unchanged. +- One switch to turn it all off (`animations = false`, `--noanimations`), yielding today's exact rendering. +- No new theme roles; no breakage for user theme files. + +**Non-Goals:** + +- Animating loading feedback (design doc §4.10's static-text rule stands for listings, Info panel, drive select, progress dialog counters/gauge). +- Multiple intensity levels, OS reduced-motion detection, or a runtime Options toggle. +- Changing any overlay's final geometry, content, timing (800 ms hold), or key handling. +- ASCII-art logos or new glyphs outside the CP437 set. + +## Decisions + +### D1: Animations are render-only interpolation of `clock_ms − stamp`; the reducer only records when things opened + +The reducer transitions state exactly as today and additionally stamps the open time of the affected overlay with `state.clock_ms` (the last `Tick` reading — no clock read inside `update`, so it stays pure). Views compute `elapsed = state.clock_ms.saturating_sub(stamp)` and draw the frame for that instant via helpers in a new `filecommand-core::anim` module (`progress(elapsed, duration) -> 0..=1000` per-mille, `reveal_count(progress, total)`, `sweep_pos(progress, width)`). Because the next `Tick` lands ≤ 33 ms later and always marks the frame dirty, a 100 ms animation gets ~4 frames with no event-loop changes. Alternative considered: an `Effect::Animate` that schedules extra redraws — rejected; the heartbeat already exists. Alternative: passing a real `now` into `render` — rejected; using `clock_ms` for both stamp and now keeps `render(State)` a pure function of state and makes snapshots trivially reproducible. Trade-off: sustained key-repeat faster than 33 ms suppresses ticks and holds an in-flight animation on its current frame until input pauses; accepted (the state underneath is already final). + +### D2: No new theme roles — animation colors reuse existing roles + +Adding roles would make every existing user `themes/*.toml` fail validation. Instead: splash reveal intermediate = `splash.frame`; splash title sweep window = `splash.version`; pop-up highlight settle = `menu.hotkey`; unrevealed cells = `screen.backdrop` (or the surface's own body style). This keeps the splash inside its spec'd `splash.*` roles and holds for hue-free themes (`nc-mono`, `inverted`, `terminal-green`, `yellow-storm`): the structural motion (trace, unfold, iris, wipe) is glyph-based and theme-independent, and the color steps degrade to subtle or invisible differences without ever carrying meaning. Alternative considered: optional `anim.*` roles with built-in defaults when absent — rejected as a second role-resolution path for a purely decorative feature. + +### D3: One shared "reveal" helper renders the final surface to a scratch buffer and blits a sub-rectangle + +For the dialog iris and the panel wipe, the view renders the finished surface into a scratch `ratatui::Buffer` sized to its final rectangle, then copies onto the screen only the cells inside the reveal window — a centered rectangle growing from 2×1 to full size (iris) or the top `n` rows (wipe). This needs no per-dialog changes: `render_phase` wraps whichever modal dialog view is active. Content under the window is the *finished* dialog, so the user sees the real dialog growing, never placeholder art. Hit-maps are built from final geometry, so a click during the 100 ms iris lands where the button will be. Alternative: per-dialog frame-drawing animations — rejected; ~12 dialog views would each need edits. + +### D4: Stamps live on the state that opened + +- `MenuState { opened_at_ms, pulldown_opened_at_ms, selected_at_ms }` — `pulldown_opened_at_ms` is re-stamped on Left/Right traversal and hotkey jumps (the new pull-down unfolds while open and interactive); `selected_at_ms` on every selection change. +- `UserMenuState { opened_at_ms, selected_at_ms }`, `FileActionMenuState { opened_at_ms, selected_at_ms }` — same unfold/settle treatment via one shared pop-up helper. +- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help`/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. +- `State::quit_opened_at_ms` — the quit dialog layers over others, so it has its own stamp and the underlying dialog does not re-animate. +- `State::panels_revealed_at_ms: Option` — set on the `Splash → Panels` transition only (hold elapsed or key). `None` when the splash was skipped, so frame 1 with `--nosplash` is still the complete panels. +- `UiPhase::Splash { started_at_ms }` already exists and drives the splash entrance. + +### D5: `State::animations` defaults off in `State::empty`, on in `Config::default` + +`Config::default().animations = true` so real launches animate; `apply_config` copies it into `State::animations`; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. + +### D6: Fixed timing budget, all ≤ the existing splash hold + +| Animation | Duration | Notes | +|---|---|---| +| Splash frame trace | 0–200 ms | perimeter cells revealed clockwise from top-left | +| Splash identity reveal | 200–560 ms | line *i* starts at 200 + 80·i, lasts 120 ms, reveals outward from center; revealed chars in `splash.frame` for ≤ 66 ms, then final role | +| Splash title sweep | 560–760 ms | 3-cell `splash.version` window crosses the product name once | +| Panel wipe after splash | 150 ms | top-to-bottom rows | +| Menu bar sweep | 100 ms | left-to-right columns | +| Pop-up menu unfold | 100 ms | frame top row first, rows added downward, bottom edge tracks | +| Highlight settle | 50 ms | new highlight row in `menu.hotkey` style, then `menu.highlight` | +| Dialog iris | 100 ms | centered window from 2×1 to full rectangle | + +Durations are constants in `core::anim` so tests reference them by name. Everything on the splash completes by 760 ms, inside the unchanged 800 ms minimum hold. + +### D7: Disable precedence mirrors `splash`/`mouse` + +`animations = false` in the general options of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. + +## Risks / Trade-offs + +- [Mid-animation frame appears in a snapshot that did not intend it] → `State::empty` has animations off; only tests that set `animations = true` see motion, and they pin `clock_ms` at named offsets. +- [Dialog iris briefly hides the dialog title so a user cannot read what opened for ~100 ms] → 100 ms is ≈ 4 frames; the window reveals the center (title/prompt region) first; disable flag for users who dislike it. +- [Scratch-buffer blit every frame while a dialog irises] → bounded to ≤ 4 frames per open; a dialog never exceeds the terminal minus the 2-cell overlay margin (the largest, the Help window, is 62×19 at 80×24), so each blit is at most a few thousand cells. +- [Hue-free themes show little color change] → structural motion carries the effect; color steps are decorative, consistent with the mono-theme rule that color never carries meaning. +- [Key-repeat starvation of `Tick` freezes an animation mid-way] → state is already final; animation resumes/completes on the next idle poll. Accepted (D1). +- [Progress/conflict/error dialogs iris during a file job] → dialog state is final and Cancel/Retry buttons are live from frame 1; the iris is cosmetic. + +## Open Questions + +- None. Surfaces, intensity, disable mechanism, and design-doc scoping were settled with the user. diff --git a/openspec/changes/ui-animations/proposal.md b/openspec/changes/ui-animations/proposal.md new file mode 100644 index 0000000..662de75 --- /dev/null +++ b/openspec/changes/ui-animations/proposal.md @@ -0,0 +1,35 @@ +# Change: ui-animations + +## Why + +FileCommand's chrome appears and disappears instantaneously: the splash box pops in fully formed, F9 drops a complete pull-down in one frame, dialogs snap open. That is faithful to a 1990s text-mode redraw, but it leaves no room for the small flourishes a modern terminal can afford — a frame tracing itself in, a menu unfolding, a highlight settling — that make the application feel alive and give visual continuity between states. The user asked for ASCII animations on application load and in the menus, as color changes and more elaborate UI motion, in keeping with the Norton Commander 5.5 aesthetic. + +The design doc (§4.10) says FileCommand "never uses spinners or animation glyphs" for *loading feedback*. This change deliberately supersedes that rule for **decorative chrome** — splash, menus, dialogs — while keeping every loading affordance (streaming listings, `Reading… N`, Info-panel `…`, drive-label fill-in) exactly as static as today. + +## What Changes + +- **Motion model** (new `ui-animations` capability): every animation is a pure function of time elapsed since a state transition, computed from the reducer's existing `Tick` clock reading. The reducer transitions state instantly and only records *when* an overlay opened; the renderer interpolates. Input is never blocked, no hold is lengthened, and any key applies to the final state immediately. +- **Startup splash entrance**: the double-line frame traces itself clockwise from the top-left corner, the four identity lines reveal outward from their centers (appearing first in the frame color, then settling to their final role), and a single highlight sweep crosses the product name — all inside the existing 800 ms minimum hold. After the splash, the panels wipe in top-to-bottom over 150 ms. +- **Menus**: on F9 the menu bar sweeps in left-to-right; every pop-up menu (F9 pull-downs, F2 user menu, Enter file-action menu) unfolds top-down from its frame; when the highlighted item changes, the new highlight row renders one frame in the hotkey accent before settling to the normal highlight style. +- **Modal dialogs**: every modal dialog opens with a 100 ms "iris" — a growing centered window onto the finished dialog — instead of appearing fully formed. +- **Configuration**: `animations = true` (default) in `config.toml` general options; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. +- **No new theme roles**: all animation colors come from roles that already exist (`splash.frame`, `splash.version`, `menu.hotkey`, `screen.backdrop`), so user `themes/*.toml` files keep validating. +- **Determinism**: animation progress is derived from `State::clock_ms`, so `insta` snapshot tests pin a clock and capture mid-animation frames; states built without config keep animations off, so existing snapshots are unchanged. + +## Capabilities + +### New Capabilities + +- `ui-animations`: the render-only motion model and its clock source; the `animations` config key and `--noanimations` flag; the modal-dialog iris; the pop-up menu unfold and highlight settle (shared by F9 pull-downs, F2 user menu, Enter action menu); the post-splash panel reveal; and the scoping that loading feedback remains static. + +### Modified Capabilities + +- `startup-splash`: "Frame-1 splash rendering" is reworded so frame 1 is the splash *backdrop plus the first frame of the entrance* when animations are enabled (the complete box when disabled); a new "Splash entrance animation" requirement defines the frame trace, identity-line reveal, and title sweep within the 800 ms hold. Minimum-hold, key-dismissal, disable, and resize requirements are unchanged. +- `pulldown-menus`: adds "Menu bar entrance sweep" for the F9 bar; existing bar, pull-down, navigation, and content requirements are unchanged (horizontal traversal still opens the adjacent pull-down with no intermediate closed state — it unfolds while already open and interactive). + +## Impact + +- **Crates:** `filecommand-core` — new `anim` module (progress/easing helpers, duration constants); `opened_at_ms`/`selected_at_ms` stamps on `MenuState`, `UserMenuState`, `FileActionMenuState`; `State::animations` flag, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; reducer sets stamps from `state.clock_ms` where overlays open; `config.rs` `animations` key. `filecommand-tui` — `--noanimations` in `parse_launch_args`, `apply_config` wiring, `print_usage` line; `splash.rs`, `menubar.rs`, `user_menu.rs`, `file_action_menu.rs` interpolated rendering; a shared scratch-buffer "reveal" helper in `views/mod.rs` used by the dialog iris and the panel wipe; new snapshot tests. +- **Docs:** README flag table and `config.toml` key table gain `--noanimations` / `animations`. +- **Depends on:** `startup-splash` (clock + hold), `pulldown-menus`, `user-menu`, `file-action-menu`, `responsive-layout` (overlay geometry), `theme-system` (role-only colors). The `help-flags` change's usage block gains the new flag. +- **Out of scope:** animating loading feedback (listings, Info panel, drive select, progress counters); a reduced-motion/"subtle" intermediate level; a runtime Options toggle; sound; ASCII-art logo; per-theme animation overrides. diff --git a/openspec/changes/ui-animations/specs/pulldown-menus/spec.md b/openspec/changes/ui-animations/specs/pulldown-menus/spec.md new file mode 100644 index 0000000..8985c89 --- /dev/null +++ b/openspec/changes/ui-animations/specs/pulldown-menus/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Menu bar entrance sweep + +When animations are enabled (`ui-animations`), the system SHALL reveal the F9 menu bar left-to-right over 100 ms: columns of the bar row are painted from the left edge, uncovering the panels' top-border row progressively, with each menu title appearing as the sweep passes its position; the `Left` pull-down unfolds concurrently per `ui-animations`. From 100 ms the bar SHALL be identical to the non-animated bar. Closing the bar SHALL be instant. The bar SHALL be fully interactive from the first frame: hotkey letters and arrows act during the sweep exactly as afterwards. + +#### Scenario: Bar sweeps in + +- **WHEN** the user presses F9 and 50 ms have elapsed on the reducer clock +- **THEN** roughly the left half of the top row is the black-on-cyan bar showing `Left` and `Files`, the right half still shows the panels' top borders and the clock +- **AND** at 100 ms or later the full-width bar with all five titles is drawn identically to the pre-animation bar + +#### Scenario: Hotkey during the sweep + +- **WHEN** the bar is 30 ms into its sweep and the user presses the `Commands` hotkey letter +- **THEN** `Commands` becomes the active title and its pull-down opens, as it would after the sweep + +#### Scenario: Closing is instant + +- **WHEN** the bar is open and the user presses Esc with no pull-down showing +- **THEN** the bar is removed on the next frame with no reverse sweep diff --git a/openspec/changes/ui-animations/specs/startup-splash/spec.md b/openspec/changes/ui-animations/specs/startup-splash/spec.md new file mode 100644 index 0000000..ec09cad --- /dev/null +++ b/openspec/changes/ui-animations/specs/startup-splash/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: Frame-1 splash rendering + +The system SHALL render the startup splash as the very first painted frame from static identity data — a solid blue backdrop with a horizontally and vertically centered double-line box containing the product name, version, copyright, and tribute lines — so that first paint never waits on any I/O. When animations are enabled (`ui-animations`), frame 1 SHALL be the backdrop plus the first frame of the splash entrance animation and the complete box SHALL be reached within the minimum hold; when animations are disabled the complete box SHALL be frame 1. The identity lines (name, version, copyright, tribute) SHALL be defined in a single place in `core` and used verbatim; the version string SHALL be the crate version. The terminal cursor SHALL be hidden while the splash is shown, and all colors SHALL come from the `splash.*` theme roles. + +#### Scenario: Splash is the first frame + +- **WHEN** FileCommand launches at or above 80×24 with the splash enabled +- **THEN** the first painted frame is the splash: a solid blue backdrop with, when animations are disabled, the complete centered double-line box, or, when enabled, the opening frame of its entrance +- **AND** the completed box contains the product name, `Version `, the copyright line, and the tribute line +- **AND** it paints before the first directory listing has completed + +#### Scenario: Splash uses theme roles and hides the cursor + +- **WHEN** the splash renders under `nc-classic` +- **THEN** the frame is drawn in `splash.frame` (cyan on blue), the name in `splash.title` (bright-white), the version in `splash.version` (white), and the copyright/tribute in `splash.text` (cyan) +- **AND** the terminal cursor is hidden + +#### Scenario: Identity lines are a single source of truth + +- **WHEN** the splash renders its identity lines +- **THEN** they are read from the single `core` definition of the identity lines (name, version, copyright, tribute) rather than a splash-local copy + +#### Scenario: Mono theme rendering + +- **WHEN** the splash renders under `nc-mono` +- **THEN** it renders white on black + +## ADDED Requirements + +### Requirement: Splash entrance animation + +When animations are enabled, the system SHALL animate the splash entrance within the 800 ms minimum hold using only `splash.*` roles and the existing box glyphs: from 0 to 200 ms the double-line frame SHALL trace itself clockwise from the top-left corner; from 200 ms each identity line SHALL reveal outward from its center over 120 ms, starting 80 ms after the previous line, with newly revealed characters drawn in `splash.frame` for up to 66 ms before settling to the line's final role; from 560 to 760 ms a 3-cell window in `splash.version` SHALL sweep once left-to-right across the product name. From 760 ms the splash SHALL be identical to the non-animated splash. A key press at any point SHALL dismiss the splash entirely (not skip to its final frame) and be consumed, exactly as today; the minimum hold SHALL remain 800 ms. + +#### Scenario: Frame traces in + +- **WHEN** 100 ms have elapsed on the reducer clock since the splash started +- **THEN** roughly half the box perimeter is drawn, beginning at the top-left corner and proceeding clockwise, and no identity text is visible yet + +#### Scenario: Identity lines reveal outward + +- **WHEN** 400 ms have elapsed +- **THEN** the frame is complete, the product name and version lines are fully revealed, and the copyright line is partially revealed from its center outward + +#### Scenario: Title sweep then steady state + +- **WHEN** 650 ms have elapsed +- **THEN** a 3-cell `splash.version` window is positioned part-way across the product name +- **AND** at 760 ms or later the splash is identical to the pre-animation splash + +#### Scenario: Key mid-animation dismisses entirely + +- **WHEN** a key is pressed 150 ms after the splash started +- **THEN** the splash is dismissed and the panels appear (wiping in per `ui-animations`), the key is consumed, and no further splash frame is drawn + +#### Scenario: Disabled renders the finished splash + +- **WHEN** animations are disabled and the splash is shown +- **THEN** every splash frame is the complete box diff --git a/openspec/changes/ui-animations/specs/ui-animations/spec.md b/openspec/changes/ui-animations/specs/ui-animations/spec.md new file mode 100644 index 0000000..b759a42 --- /dev/null +++ b/openspec/changes/ui-animations/specs/ui-animations/spec.md @@ -0,0 +1,129 @@ +## ADDED Requirements + +### Requirement: Render-only motion model driven by the reducer clock + +The system SHALL implement every animation as a pure function of the elapsed time between the reducer's most recent `Tick` clock reading (`State::clock_ms`) and a timestamp the reducer recorded when the animated surface opened or changed. The reducer SHALL transition state instantly exactly as it does without animations and SHALL only additionally record these timestamps from `State::clock_ms`; it SHALL NOT read a clock, delay any transition, or lengthen any hold. Renderers SHALL interpolate from the recorded timestamp, and every key or mouse command SHALL apply to the final (non-animated) state immediately. Animations SHALL use only CP437-heritage glyphs and colors obtained from existing theme roles; no new theme role SHALL be introduced. + +#### Scenario: Input during an animation applies to the final state + +- **WHEN** a pull-down menu is 30 ms into its 100 ms unfold and the user presses Down +- **THEN** the selection moves to the next enabled item exactly as it would with animations disabled +- **AND** the pull-down continues unfolding around the already-moved highlight + +#### Scenario: Animation progress comes from the reducer clock, not a live clock + +- **WHEN** a state with an overlay stamped at `clock_ms = 1000` is rendered twice with `clock_ms = 1050` +- **THEN** both renders produce identical output +- **AND** no renderer or reducer reads a system clock to produce the frame + +#### Scenario: No new theme roles + +- **WHEN** a user theme file that defines exactly today's role set is loaded with animations enabled +- **THEN** it validates and every animation renders using only roles present in that file + +### Requirement: Animations are disabled by config or launch flag + +The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set in `config.toml` general options or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's flag table. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. + +#### Scenario: Default is animated + +- **WHEN** FileCommand launches with no `animations` key in config and no `--noanimations` flag +- **THEN** the splash, menus, and dialogs animate + +#### Scenario: Disabled via config + +- **WHEN** `animations = false` is set and the user presses F9 +- **THEN** the menu bar and `Left` pull-down render complete on the first frame, byte-identical to the pre-animation rendering + +#### Scenario: Flag overrides config + +- **WHEN** FileCommand launches with `--noanimations` while config has `animations = true` +- **THEN** no surface animates + +#### Scenario: Flag is documented in usage + +- **WHEN** the user runs `filecommand --help` +- **THEN** the flag table includes `--noanimations` + +#### Scenario: States built without config do not animate + +- **WHEN** a snapshot test renders a state created via `State::empty` with an open dialog +- **THEN** the dialog renders in its final form regardless of `clock_ms` + +### Requirement: Pop-up menus unfold and highlights settle + +The system SHALL open every pop-up menu — the F9 pull-down boxes, the F2 user menu, and the Enter file-action menu — by unfolding it top-down over 100 ms: the frame's top row appears first, item rows are added downward in order, and the bottom frame edge tracks the last revealed row, so the box is never taller than its final rectangle. A pull-down opened by Left/Right traversal or a hotkey jump SHALL unfold the same way while remaining open and interactive. Whenever the highlighted item changes, the newly highlighted row SHALL render in the `menu.hotkey` role for up to 50 ms and then settle to `menu.highlight`. The menu's final rendering, geometry, and content SHALL be unchanged from today. + +#### Scenario: Pull-down unfolds top-down + +- **WHEN** the user presses F9 and 50 ms have elapsed on the reducer clock +- **THEN** the `Left` pull-down shows its top frame row and roughly the first half of its item rows, closed by a bottom frame edge +- **AND** at 100 ms or later the pull-down is complete and identical to the pre-animation rendering + +#### Scenario: Traversal re-unfolds the adjacent pull-down + +- **WHEN** the `Files` pull-down is open and the user presses Right +- **THEN** the `Commands` pull-down begins unfolding immediately with no closed intermediate frame +- **AND** Down pressed during the unfold moves the `Commands` selection + +#### Scenario: Highlight settles after moving + +- **WHEN** the user presses Down in an open pull-down and the frame is rendered within 50 ms +- **THEN** the newly selected row renders in the `menu.hotkey` style +- **AND** a frame rendered 50 ms or later shows it in `menu.highlight` + +#### Scenario: User menu and file-action menu share the treatment + +- **WHEN** the user presses F2, or Enter on a file +- **THEN** the user menu or file-action menu unfolds top-down over 100 ms and its highlight settles exactly as the F9 pull-downs do + +### Requirement: Modal dialogs open with an iris + +The system SHALL open every modal dialog — operation input/confirmation/overwrite/error/progress/summary dialogs, Help and About, theme picker, drive select, find file, fuzzy jump, the quit confirmation, and the startup warning — by revealing the finished dialog through a centered rectangular window that grows from the dialog's center to its full rectangle over 100 ms. The dialog's state, final geometry, content, buttons, and hit regions SHALL be those of the finished dialog from the first frame, so clicks and keys during the iris behave exactly as when it is complete. A dialog that replaces another in the same slot (for example progress → overwrite conflict) SHALL iris again; the quit confirmation layered over another dialog SHALL iris without re-animating the dialog beneath it. + +#### Scenario: Dialog grows from its center + +- **WHEN** the user presses F7 and 50 ms have elapsed on the reducer clock +- **THEN** a centered window roughly half the dialog's width and height shows the central portion of the finished Make-directory dialog +- **AND** at 100 ms or later the dialog is complete and identical to the pre-animation rendering + +#### Scenario: Buttons work during the iris + +- **WHEN** the quit-confirmation dialog is 30 ms into its iris and the user presses Enter +- **THEN** the default button activates exactly as it would after the iris completed + +#### Scenario: Replacement dialog irises; layered quit dialog does not re-animate the one beneath + +- **WHEN** a running copy job raises an overwrite-conflict dialog in place of the progress dialog +- **THEN** the conflict dialog irises in +- **WHEN** the user then presses Esc over the panels with a dialog open and the quit confirmation opens over it +- **THEN** only the quit confirmation irises; the dialog beneath stays fully drawn + +### Requirement: Panels wipe in after the splash + +The system SHALL, when the splash is replaced by the panels (minimum hold elapsed or dismissing key), reveal the panel screen top-to-bottom over 150 ms, with unrevealed rows drawn in `screen.backdrop`. When the splash was skipped (`splash = false`, `--nosplash`, or below-floor placeholder), the panels SHALL be frame 1 in complete form with no wipe. + +#### Scenario: Panels wipe in after the hold + +- **WHEN** the splash hold elapses and 75 ms have passed on the reducer clock +- **THEN** roughly the top half of the panel screen is drawn and the rest is backdrop +- **AND** at 150 ms or later the screen is identical to the pre-animation panel rendering + +#### Scenario: No wipe when the splash is skipped + +- **WHEN** FileCommand launches with `--nosplash` +- **THEN** the first painted frame is the complete panel screen + +### Requirement: Loading feedback stays static + +The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses animation" statement for decorative chrome only. + +#### Scenario: Streaming listing has no spinner + +- **WHEN** a large directory is streaming into a panel with animations enabled +- **THEN** the mini-status shows `Reading… N` updated in place and no spinner, sweep, or glyph cycling appears anywhere in the panel + +#### Scenario: Progress gauge is not animated + +- **WHEN** a copy job's progress dialog is complete (iris finished) and bytes are transferring +- **THEN** the `█`/`░` gauge and counters change only when a progress event arrives diff --git a/openspec/changes/ui-animations/tasks.md b/openspec/changes/ui-animations/tasks.md new file mode 100644 index 0000000..40e61d1 --- /dev/null +++ b/openspec/changes/ui-animations/tasks.md @@ -0,0 +1,38 @@ +# Tasks: ui-animations + +## 1. Core motion model + +- [ ] 1.1 Add `filecommand-core::anim` with per-mille `progress`, `reveal_count`, `sweep_pos`, iris-rect helper, and the named duration constants from design D6 (ui-animations: "Render-only motion model driven by the reducer clock") +- [ ] 1.2 Add `State::animations`, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; `State::empty` sets `animations = false` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 1.3 Stamp `MenuState` (`opened_at_ms`, `pulldown_opened_at_ms`, `selected_at_ms`), `UserMenuState` and `FileActionMenuState` (`opened_at_ms`, `selected_at_ms`) from `state.clock_ms` in the reducer on open, traversal, and selection change (ui-animations: "Pop-up menus unfold and highlights settle") +- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") + +## 2. Configuration and launch flag + +- [ ] 2.1 Parse `animations = ` in `config::parse` (general options; default `true`) (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run`/`apply_config` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.3 Add `--noanimations` to `main.rs` `print_usage` and to the README flag table; add `animations` to the README config key table (ui-animations: "Animations are disabled by config or launch flag") + +## 3. Splash and panel reveal + +- [ ] 3.1 Animate `views/splash.rs`: clockwise frame trace, center-out identity-line reveal with `splash.frame` intermediate, `splash.version` title sweep; final frame identical to today's at ≥ 760 ms or when disabled (startup-splash: "Splash entrance animation"; "Frame-1 splash rendering") +- [ ] 3.2 Add the shared scratch-buffer reveal helper in `views/mod.rs` (iris window and top-rows wipe) (ui-animations: "Modal dialogs open with an iris") +- [ ] 3.3 Wipe the panels top-to-bottom over 150 ms when `panels_revealed_at_ms` is set (ui-animations: "Panels wipe in after the splash") + +## 4. Menus + +- [ ] 4.1 Menu bar left-to-right sweep in `views/menubar.rs` (pulldown-menus: "Menu bar entrance sweep") +- [ ] 4.2 Shared pop-up unfold + highlight-settle helper applied to F9 pull-downs, `views/user_menu.rs`, and `views/file_action_menu.rs` (ui-animations: "Pop-up menus unfold and highlights settle") + +## 5. Dialogs + +- [ ] 5.1 Wrap every modal dialog view in `render_phase`/`render` with the iris helper keyed on `dialog_opened_at_ms` (quit dialog on `quit_opened_at_ms`) (ui-animations: "Modal dialogs open with an iris") +- [ ] 5.2 Confirm hit-maps still use final geometry during the iris (ui-animations: "Modal dialogs open with an iris") + +## 6. Tests + +- [ ] 6.1 Unit tests for `anim` helpers (progress clamping, reveal counts at boundaries, iris rect never exceeds final rect) (ui-animations: "Render-only motion model driven by the reducer clock") +- [ ] 6.2 Reducer tests: stamps set from `clock_ms` on open/traversal/selection; key during splash still dismisses and is consumed; `panels_revealed_at_ms` is `None` when splash skipped (ui-animations; startup-splash: "Minimum hold and key dismissal") +- [ ] 6.3 Config/flag tests: default on, `animations = false`, `--noanimations` wins over `animations = true` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 6.4 `insta` snapshots with `animations = true` and pinned `clock_ms`: splash at 0 / 100 / 400 / 650 / 800 ms; pull-down at 0 / 50 / 100 ms; dialog iris at 0 / 50 / 100 ms; panel wipe at 75 ms; menu bar sweep at 50 ms (startup-splash; pulldown-menus; ui-animations) +- [ ] 6.5 Verify the full existing snapshot suite is byte-identical (animations off in `State::empty`) (ui-animations: "Animations are disabled by config or launch flag") From 28b1794342194073cadb85f4bd819010b565676b Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Thu, 8 Oct 2026 09:12:11 -0600 Subject: [PATCH 02/14] Add OpenSpec proposal reread-preserves-cursor After a confirmed delete (and every other same-directory re-read: copy/move/mkdir completion, Ctrl+R, stale-tab refresh) the panel jumps back to the top because Panel::begin_new_listing resets cursor and scroll offset unconditionally. This proposal adds a re-read anchor so the cursor returns to its entry or the nearest survivor (next after a delete, Norton Commander style) and the scroll offset is restored and re-clamped, applied while the listing streams so there is no transient jump. Modifies panel-navigation and file-operations. Co-Authored-By: Claude Fable 5.1 --- .../reread-preserves-cursor/.openspec.yaml | 2 + .../changes/reread-preserves-cursor/design.md | 66 +++++++++++++++ .../reread-preserves-cursor/proposal.md | 31 +++++++ .../specs/file-operations/spec.md | 25 ++++++ .../specs/panel-navigation/spec.md | 81 +++++++++++++++++++ .../changes/reread-preserves-cursor/tasks.md | 37 +++++++++ 6 files changed, 242 insertions(+) create mode 100644 openspec/changes/reread-preserves-cursor/.openspec.yaml create mode 100644 openspec/changes/reread-preserves-cursor/design.md create mode 100644 openspec/changes/reread-preserves-cursor/proposal.md create mode 100644 openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md create mode 100644 openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md create mode 100644 openspec/changes/reread-preserves-cursor/tasks.md diff --git a/openspec/changes/reread-preserves-cursor/.openspec.yaml b/openspec/changes/reread-preserves-cursor/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/reread-preserves-cursor/design.md b/openspec/changes/reread-preserves-cursor/design.md new file mode 100644 index 0000000..31d1565 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/design.md @@ -0,0 +1,66 @@ +# Design: reread-preserves-cursor + +## Context + +Every directory read in `filecommand-core` goes through `update::begin_listing` → `begin_listing_inner` → `Panel::begin_new_listing(cwd)` (`crates/filecommand-core/src/panel.rs`). That method resets `cursor = 0`, `scroll_offset = 0`, and `cursor_user_moved = false`, then the worker streams `Command::ListingChunk` events whose `insert_streamed` keeps the cursor pinned to 0 until the user moves (panel-navigation "Streamed listing keeps the top pinned until the user moves"). `Command::ListingComplete` clamps the cursor, reconciles the selection, consumes find-file's `pending_cursor_target` (if any) to settle the cursor on a named entry, and calls `reconcile_panel_viewport` to re-clamp the offset. + +That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today: + +- `Command::JobDone` — re-reads every active tab whose `cwd` matches the job's source or destination directory (file-operations "Automatic panel re-read on completion"). +- `Command::RereadPanel` — Ctrl+R and the Left/Right menu's Re-read item. +- Stale background-tab refresh on `SwitchTab` / `CloseTab` activation (panel-tabs "Stale background tab refresh on activation"). + +The user-visible symptom that prompted this change: confirm a delete partway down a long listing, and the panel jumps back to the top. + +## Goals / Non-Goals + +**Goals:** + +- No visual jump on any same-directory re-read: the cursor stays on its entry and the window stays where it was. +- Norton Commander landing rule after a delete: the entry that followed the deleted one; the one before it if the last entry was deleted; for a multi-selection delete, the first survivor at or after the cursor's old row. +- One mechanism shared by every re-read caller — no per-caller special cases. +- Core-only change, fully testable through `core::update` without a terminal. + +**Non-Goals:** + +- Tree display mode (its cursor and offset live in `TreeState` and it does not re-read through this path). +- Preserving the quick filter or the selection set across a re-read — `begin_new_listing` keeps clearing both; `ListingComplete`'s `reconcile_selection` is unchanged. +- Any change to navigation into a different directory, to find-file's deferred settle, or to the streaming-pinned-to-top rule for fresh directories. +- Brief-mode column geometry beyond reusing the existing `ensure_cursor_visible_brief` clamp. + +## Decisions + +### D1: Anchor by entry name with a survivor fallback order, not by numeric index + +Before a same-directory re-read, the panel captures a `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` — the previous entries' names in display order plus the cursor index and offset. When the new listing arrives, the cursor resolves to the first of these names that is present in the new listing, tried in this order: `names[cursor]`, then `names[cursor + 1..]` in order, then `names[..cursor]` in reverse. That yields "same entry if it survived, else the next survivor, else the nearest previous survivor" and covers a multi-selection delete (`{b, c, d}` deleted with the cursor on `c` lands on `e`) with no special casing. + +Alternative rejected — keep the numeric cursor index and clamp: after a multi-selection delete it lands on an arbitrary entry, and it is wrong whenever the re-read changes the ordering (a copy job adding entries above the cursor). + +### D2: Apply the anchor on every `ListingChunk`, consume it on `ListingComplete` + +A re-read streams; if the anchor were applied only on completion, the cursor would sit on row 0 for the frames between the first chunk and the final event. Instead `apply_listing_event` runs the anchor lookup after each chunk (cheap: a name scan over the entries in hand) and once more on completion, then clears it. When the anchor applies it sets `cursor_user_moved = true`, so `insert_streamed`'s pin-to-top stops competing with it. Until a chunk containing a resolvable name arrives, the cursor stays at 0 — unobservable in practice, since the anchored entry is usually in the first chunk. + +Find-file's `pending_cursor_target` is checked first on `ListingComplete` and wins if set; the two never coincide today because find-file navigates to a different directory, which clears the anchor (D4). + +### D3: Restore the scroll offset, then clamp to the new list + +On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple. + +### D4: The anchor lives on `Panel` and is cleared by any different-directory navigation + +`Panel::reread_anchor: Option` sits beside `pending_cursor_target` and follows the same hygiene: `begin_new_listing(cwd)` clears it when `cwd != self.cwd`, so a navigation never inherits a stale anchor from an earlier re-read. It is included in the per-tab stash/restore data alongside `pending_cursor_target`, so a tab switched away mid-re-read keeps its anchor for when its listing completes. + +### D5: Capture in `begin_listing_inner`, so every caller benefits without plumbing + +`begin_listing_inner` is the single funnel for all three re-read callers and for ordinary navigation. It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. + +## Risks / Trade-offs + +- An O(n) snapshot of entry names per re-read — negligible against the directory read it accompanies, and freed on completion. +- A directory that changes on disk between capture and completion resolves to whatever survivor the fallback order finds; that is the intended "nearest surviving entry" behavior, not a failure mode. +- A re-read whose previous listing was still streaming keeps the old jump-to-top; accepted as the safer choice. + +## Open Questions + +- Should the quick filter survive a same-directory re-read? Today it is cleared; a follow-up proposal could keep it and re-apply it to the fresh listing. +- Should the selection set survive a copy-job re-read of the source panel? Today `begin_new_listing` clears it; separate concern. diff --git a/openspec/changes/reread-preserves-cursor/proposal.md b/openspec/changes/reread-preserves-cursor/proposal.md new file mode 100644 index 0000000..c3dbb41 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/proposal.md @@ -0,0 +1,31 @@ +# Change: reread-preserves-cursor + +## Why + +After the user confirms a delete (F8 or the Delete key), the panel re-reads its directory and the cursor and scroll position snap back to the top of the list — the user loses their place. Norton Commander keeps the cursor where it was, landing it on the entry that followed the deleted one. The same jump happens on every re-read of a directory the panel is already showing: copy/move/mkdir completion, Ctrl+R / menu Re-read, and a stale background tab's activation refresh. All of them funnel through `Panel::begin_new_listing`, which resets the cursor, the scroll offset, and the "user has moved" flag unconditionally, and the streamed listing then stays pinned to the first entry. + +## What Changes + +- A re-read of the directory a panel is **already displaying** preserves the user's position: the cursor returns to the same entry if it is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset is restored and re-clamped so the window does not extend past the end of the shorter list and the cursor stays visible. +- This applies to every same-directory re-read: file-operation job completion (delete, copy, move, mkdir — including a cancellation after partial progress), Ctrl+R / the Left/Right menu's Re-read, and the stale-background-tab refresh on activation. When both panels browse the affected directory, both keep their own positions. +- The restored position holds **while the re-read streams in**, not only on completion, so the cursor never visibly flicks to the first row. +- Navigation into a **different** directory is unchanged: cursor and offset reset to the top and the streamed listing stays pinned until the user moves. +- Find-file's deferred cursor settle keeps precedence if both mechanisms are ever set on the same listing. +- Non-breaking. Tree mode, and preserving the quick filter or the selection set across a re-read, are out of scope (they keep today's behavior). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: adds the requirement "Same-directory re-read preserves cursor and scroll position" (cursor-anchor resolution order, scroll-offset restore and re-clamp, no transient jump while streaming, different-directory navigation unchanged); "Scroll offset is core panel state" gains the re-read anchor restore in its list of re-clamp triggers. +- `file-operations`: "Automatic panel re-read on completion" additionally states that each such re-read of a panel's current directory preserves that panel's cursor entry (or nearest survivor) and scroll offset, with a scenario for the post-delete case. + +## Impact + +- **Specs:** `panel-navigation` (one added requirement, one modified requirement), `file-operations` (one modified requirement, one added scenario). +- **Code:** `crates/filecommand-core/src/panel.rs` — a re-read anchor (previous entry names in display order, cursor index, scroll offset) stored on the panel, captured before a same-directory re-read and cleared by any navigation to a different directory, plus capture/apply helpers; `crates/filecommand-core/src/update.rs` — `begin_listing_inner` captures the anchor when the target path equals the panel's current directory, and `apply_listing_event` applies it on each `ListingChunk` and on `ListingComplete` (consuming it there). Tests in `crates/filecommand-core/src/update/tests.rs` and `panel.rs`. No `filecommand-tui` changes — the renderer already only reads `cursor` and `scroll_offset`. +- **Compatibility:** additive. The only observable change is that same-directory re-reads no longer reset the cursor and scroll offset. diff --git a/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md b/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md new file mode 100644 index 0000000..be31393 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Automatic panel re-read on completion + +When a job finishes — including a cancellation after partial progress — every tab, on either panel, whose directory matches an affected path SHALL be refreshed so its listing reflects the resulting file-system state without a manual refresh. A tab that is currently active on its panel SHALL re-read immediately, reusing the streaming listing path. A tab that is not currently active (a background tab — see `panel-tabs`) SHALL instead be marked stale and re-read automatically the moment it becomes active, rather than eagerly re-read while off-screen. Each such re-read of a panel's current directory SHALL preserve that panel's cursor entry (or its nearest survivor) and scroll offset as specified by `panel-navigation` — "Same-directory re-read preserves cursor and scroll position". + +#### Scenario: Panels refresh after a completed operation +- **WHEN** a copy/move/delete/mkdir job completes +- **THEN** the source and/or destination panels re-read and display the updated directory contents automatically + +#### Scenario: Panels refresh after a cancelled operation with partial changes +- **WHEN** a job is cancelled after it has already changed some files on disk +- **THEN** the affected panels still re-read automatically so the listing reflects the partial result + +#### Scenario: The opposite panel sharing the affected directory also refreshes +- **WHEN** both panels are browsing the same directory and a delete job completes in the active panel +- **THEN** the opposite (inactive) panel also re-reads automatically and no longer shows the deleted entry + +#### Scenario: A background tab on the affected directory is marked stale, not eagerly re-read +- **WHEN** a panel has a background tab (not the active tab) browsing a directory affected by a completed job +- **THEN** that background tab is marked stale rather than re-read immediately, and its cached listing is left untouched until it becomes active + +#### Scenario: Post-delete re-read keeps the user's place +- **WHEN** a delete job completes in a panel scrolled partway down a long listing +- **THEN** the panel re-reads, the deleted entry is gone, the window stays where it was, and the cursor is on the entry that followed the deleted one diff --git a/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md b/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md new file mode 100644 index 0000000..30f7545 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Same-directory re-read preserves cursor and scroll position + +When a panel re-reads the directory it is already displaying — after a file-operation job completes or is cancelled, on Ctrl+R or the Left/Right menu's Re-read item, or on a stale background tab's activation refresh — the system SHALL restore the cursor to the entry it was on if that entry is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset SHALL be restored to its previous value and then re-clamped so the window does not extend past the end of the new list and the cursor remains visible. The restored position SHALL hold while the re-read streams in — the cursor SHALL NOT transiently jump to the first entry — and after completion. Navigation into a different directory SHALL continue to reset the cursor and offset to the top. For same-directory re-reads this requirement takes precedence over "Streamed listing keeps the top pinned until the user moves". + +#### Scenario: Deleting the cursored file lands on the next entry + +- **WHEN** the cursor is on `b.txt` in a listing `a.txt`, `b.txt`, `c.txt` and the user confirms a delete of `b.txt` that completes +- **THEN** after the automatic re-read the cursor is on `c.txt` and the scroll offset is unchanged + +#### Scenario: Deleting the last entry lands on the previous one + +- **WHEN** the cursor is on the last entry of a listing and the user confirms a delete of that entry that completes +- **THEN** after the re-read the cursor is on the entry that preceded it, now the last entry + +#### Scenario: Multi-selection delete lands on the first survivor at or after the cursor + +- **WHEN** a listing `a`, `b`, `c`, `d`, `e` has `b`, `c`, `d` selected with the cursor on `c`, and a delete of the selection completes +- **THEN** after the re-read the cursor is on `e` + +#### Scenario: Scrolled window stays put + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 30 and the cursor on position 40, and a delete of the cursored entry completes +- **THEN** after the re-read the scroll offset is still 30 and the cursor is on position 40, which is the entry formerly at position 41 + +#### Scenario: Deleting at the very bottom pulls the window up by one + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 40 and the cursor on the last entry, and a delete of that entry completes +- **THEN** after the re-read the list has 59 entries, the scroll offset is re-clamped to 39, the cursor is on the new last entry, and no blank trailing row is shown + +#### Scenario: Ctrl+R keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and the user presses Ctrl+R +- **THEN** after the re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: No jump while the re-read streams + +- **WHEN** a same-directory re-read is in progress and its first streamed chunk contains the anchored entry +- **THEN** the cursor is on that entry as soon as the chunk is applied, not on the first entry + +#### Scenario: Navigating to a different directory still starts at the top + +- **WHEN** the cursor is partway down a listing and the user presses Enter on a subdirectory +- **THEN** the new listing starts with the cursor on its first entry and the scroll offset at 0 + +## MODIFIED Requirements + +### Requirement: Scroll offset is core panel state + +Each panel's scroll offset SHALL live in `filecommand-core` panel state (with Tree mode's offset in its tree state), and all offset changes SHALL flow through `core::update` — the renderer only reads it. The offset SHALL be a position in the quick-filter-narrowed visible list, not a raw entry index. Core SHALL derive each panel's body row count from state it already holds (terminal size from `Resize`, the panel split, the panel's display mode, and tab-strip visibility) — it SHALL NOT query the terminal — and SHALL re-clamp the offset so the cursor stays visible after every mutation that can move the cursor or change the list: cursor movement, quick-filter edits, re-sort, streamed listing updates, directory load completion (including find-file's deferred cursor settle and the same-directory re-read anchor restore), tab restore, and terminal resize. + +#### Scenario: Quick-filter narrowing re-clamps the offset + +- **WHEN** a quick-filter keystroke narrows the visible list so the current offset would leave the cursor's entry outside the window +- **THEN** the offset is re-clamped in the same reducer step so the cursor's entry is visible + +#### Scenario: Re-sort keeps the cursor's entry in view + +- **WHEN** the sort mode changes and the cursor re-anchors to the same entry at a new position +- **THEN** the offset is re-clamped so that entry is inside the window + +#### Scenario: Terminal resize re-clamps + +- **WHEN** the terminal shrinks so the current offset would leave the cursor below the new, shorter window +- **THEN** the next reducer step after the `Resize` re-clamps the offset and the cursor is visible in the new geometry + +#### Scenario: Tab restore re-clamps against the current viewport + +- **WHEN** a tab stashed with a scroll offset is restored while the panel's body height differs from when it was stashed +- **THEN** the restored offset is re-clamped so the restored cursor is visible at the current height + +#### Scenario: Streamed listing keeps the top pinned until the user moves + +- **WHEN** entries stream into a freshly loaded directory and the user has not moved the cursor +- **THEN** the offset stays 0 with the cursor pinned to the first entry, exactly as the cursor itself already behaves + +#### Scenario: Re-read anchor restore re-clamps against the shorter list + +- **WHEN** a same-directory re-read restores a scroll offset that would leave blank rows below the end of the now-shorter list +- **THEN** the offset is re-clamped in the same reducer step so the window ends at the last entry and the cursor remains visible diff --git a/openspec/changes/reread-preserves-cursor/tasks.md b/openspec/changes/reread-preserves-cursor/tasks.md new file mode 100644 index 0000000..702ce59 --- /dev/null +++ b/openspec/changes/reread-preserves-cursor/tasks.md @@ -0,0 +1,37 @@ +# Tasks: reread-preserves-cursor + +## 1. Core panel state (`crates/filecommand-core/src/panel.rs`) + +- [ ] 1.1 Add `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` and `Panel::reread_anchor: Option`, initialized to `None` (design D1, D4) +- [ ] 1.2 Include `reread_anchor` in the per-tab stash/restore data beside `pending_cursor_target` so a tab switched away mid-re-read keeps it (design D4) +- [ ] 1.3 Add `Panel::capture_reread_anchor()` — records entry names in display order, cursor, and scroll offset only when `progress` is `Complete`; otherwise leaves the anchor `None` (design D5) +- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` and clamps it to `visible_len.saturating_sub(rows)`; returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) +- [ ] 1.5 In `begin_new_listing(cwd)`, clear `reread_anchor` when `cwd != self.cwd` so a navigation never inherits a stale anchor (design D4) + +## 2. Reducer (`crates/filecommand-core/src/update.rs`) + +- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` (design D5) +- [ ] 2.2 In `apply_listing_event`, on `ListingChunk` call `apply_reread_anchor(rows)` after the chunk is inserted and before `reconcile_panel_viewport` (panel-navigation: "No jump while the re-read streams"; design D2) +- [ ] 2.3 In `apply_listing_event`, on `ListingComplete` apply `pending_cursor_target` first (unchanged), then `apply_reread_anchor(rows)` if no find-file target was consumed, then take the anchor so it is consumed, then `reconcile_panel_viewport` (design D2) +- [ ] 2.4 Route the Brief-mode offset restore through `ensure_cursor_visible_brief` so the restored offset stays on a whole-column multiple (design D3) + +## 3. Tests + +- [ ] 3.1 `update/tests.rs`: `JobDone` delete of the cursored entry lands the cursor on the following entry with the offset unchanged (panel-navigation: "Deleting the cursored file lands on the next entry"; file-operations: "Post-delete re-read keeps the user's place") +- [ ] 3.2 `update/tests.rs`: deleting the last entry lands on the new last entry (panel-navigation: "Deleting the last entry lands on the previous one") +- [ ] 3.3 `update/tests.rs`: multi-selection delete `{b, c, d}` with the cursor on `c` lands on `e` (panel-navigation: "Multi-selection delete lands on the first survivor at or after the cursor") +- [ ] 3.4 `update/tests.rs`: 60 entries, 20-row body, offset 30, cursor at position 40; delete the cursored entry → offset 30, cursor at position 40 (panel-navigation: "Scrolled window stays put") +- [ ] 3.5 `update/tests.rs`: offset 40, cursor on the last of 60; delete it → 59 entries, offset 39, cursor on the last entry (panel-navigation: "Deleting at the very bottom pulls the window up by one"; "Re-read anchor restore re-clamps against the shorter list") +- [ ] 3.6 `update/tests.rs`: `RereadPanel` keeps the cursor on `report.txt` with the offset unchanged (panel-navigation: "Ctrl+R keeps the cursor's entry") +- [ ] 3.7 `update/tests.rs`: the first `ListingChunk` of a same-directory re-read containing the anchored name already places the cursor on it (panel-navigation: "No jump while the re-read streams") +- [ ] 3.8 `update/tests.rs`: Enter on a subdirectory after a re-read starts the new listing at cursor 0 / offset 0 (panel-navigation: "Navigating to a different directory still starts at the top") +- [ ] 3.9 `update/tests.rs`: extend `job_done_delete_rereads_both_panels_when_both_browse_the_same_directory` to assert the inactive panel's cursor entry and offset are also preserved (file-operations: "The opposite panel sharing the affected directory also refreshes") +- [ ] 3.10 `update/tests.rs`: a re-read issued while the previous listing is still `Streaming` captures no anchor and behaves as before (design D5) +- [ ] 3.11 `panel.rs`: unit tests for `apply_reread_anchor`'s resolution order and for the Brief-mode column-aligned offset restore (design D1, D3) +- [ ] 3.12 Confirm the existing `job_done_*`, `reread_*`, and find-file settle tests still pass unchanged + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green +- [ ] 4.2 `openspec validate reread-preserves-cursor --strict` clean +- [ ] 4.3 Manual: in a directory longer than the panel, scroll partway down, F8 a file → no jump, cursor on the following entry; Ctrl+R → no jump; Enter into a subdirectory → listing starts at the top From 9f7669ca1cd322aa4fa3f362d1affa491b8af8e0 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:17 -0600 Subject: [PATCH 03/14] Add OpenSpec proposal config-profile-directory Resolve one per-user profile directory (--config-dir flag, portable marker, or %APPDATA%\FileCommand) for config.toml, history.json, and usermenu.toml instead of the launch directory; create it on first run, fail soft with a startup warning, and show a legacy notice for old files in the working directory without migrating them. Co-Authored-By: Claude Fable 5.1 --- .../config-profile-directory/.openspec.yaml | 2 + .../config-profile-directory/design.md | 50 +++++++++++++++++++ .../config-profile-directory/proposal.md | 32 ++++++++++++ .../specs/application-shell/spec.md | 33 ++++++++++++ .../changes/config-profile-directory/tasks.md | 35 +++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 openspec/changes/config-profile-directory/.openspec.yaml create mode 100644 openspec/changes/config-profile-directory/design.md create mode 100644 openspec/changes/config-profile-directory/proposal.md create mode 100644 openspec/changes/config-profile-directory/specs/application-shell/spec.md create mode 100644 openspec/changes/config-profile-directory/tasks.md diff --git a/openspec/changes/config-profile-directory/.openspec.yaml b/openspec/changes/config-profile-directory/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/config-profile-directory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/config-profile-directory/design.md b/openspec/changes/config-profile-directory/design.md new file mode 100644 index 0000000..d7c53ff --- /dev/null +++ b/openspec/changes/config-profile-directory/design.md @@ -0,0 +1,50 @@ +# Design: config-profile-directory + +## Context + +`app.rs:121` calls `config::load(Path::new("config.toml"))`; `app.rs:150-158` loads `history.json` and `usermenu.toml` the same way and stores the bare names in `Runtime` for atomic saves (`save_theme_atomic`, `save_panel_split_atomic`, `save_history_file_atomic`). `config::load_user_menu` creates `usermenu.toml` with defaults when absent (user-menu "Create and recover the usermenu.toml file"). Nothing reads a `themes/` directory. The design doc §3.1 names `%APPDATA%\FileCommand`. + +## Goals / Non-Goals + +**Goals:** + +- One stable per-user location for all three files. +- A portable escape hatch and an explicit override for testing/scripting. +- No silent data movement. +- Discoverable: usage text and the warning dialog name the location. + +**Non-Goals:** + +- Automatic migration of old files. +- A config-dir entry in the Info panel or About dialog. +- Loading user theme files (separate proposal, if ever). +- Changing any file format. + +## Decisions + +### D1: Resolution order is flag → portable marker → platform default + +`--config-dir ` (also `--config-dir=`) wins outright and may be relative (resolved against the cwd at startup). Otherwise, if `/portable` exists (any file, contents ignored), the profile is ``. Otherwise `%APPDATA%\FileCommand` via `std::env::var_os("APPDATA")`; on non-Windows `$XDG_CONFIG_HOME/filecommand` else `$HOME/.config/filecommand`. No new crate: `std::env` suffices. If none of the environment variables resolve, fall back to the cwd with a startup warning (last resort, never silent). + +Alternative rejected: an environment-variable override — the flag covers scripting and avoids a second mechanism. + +### D2: Create on demand; fail soft + +`create_dir_all(profile)` before the first load. On failure the loaders still run (returning defaults for missing files), `Runtime` records `persist_enabled = false` so the three save functions are skipped, and `startup_warning` says `Could not create : — settings will not be saved this session`. + +### D3: Legacy notice is a warning, not a migration + +Only when the profile has no `config.toml` **and** the cwd has at least one of the three files: `startup_warning = "Found in ; FileCommand now reads . Move them there to keep your settings."`. The check is a pure function in `config.rs` over two `&Path`s so it is unit-tested with temp dirs. Precedence with other startup warnings follows the existing single-field behavior (last writer wins), unchanged. + +### D4: Usage text prints the resolved default + +`print_usage` adds `--config-dir Use for config.toml, history.json, usermenu.toml` and a trailing line `Default profile directory: ` so a user can find their files without reading docs. + +## Risks / Trade-offs + +- [Users with tuned `config.toml` files in project directories see the notice on every launch from such a directory] → The notice is explicit and names both paths; the alternative (silent cwd behavior) is the bug being fixed. +- [A roaming `%APPDATA%` profile means `history.json` churn roams] → It is a few KB; matches the design doc. + +## Open Questions + +- None. diff --git a/openspec/changes/config-profile-directory/proposal.md b/openspec/changes/config-profile-directory/proposal.md new file mode 100644 index 0000000..834e4af --- /dev/null +++ b/openspec/changes/config-profile-directory/proposal.md @@ -0,0 +1,32 @@ +# Change: config-profile-directory + +## Why + +FileCommand resolves `config.toml`, `history.json`, and `usermenu.toml` as bare relative paths (`app.rs` `CONFIG_FILE`, `config::HISTORY_FILE`, `config::USERMENU_FILE`), so they land in whatever directory the process was launched from. Every `cd somewhere; filecommand` creates three files there, and command history and fuzzy-jump frecency fragment per launch directory, so Ctrl+J never learns the user's habits across launches. The design doc (§3.1) and the `fuzzy-jump` spec ("persist that history in `history.json` in the platform config directory") both already call for `%APPDATA%\FileCommand`; the code never implemented it and the README documents the cwd behavior as if intended. + +## What Changes + +- A single **profile directory** is resolved once at startup and used for every configuration file: `config.toml`, `history.json`, `usermenu.toml`. +- Resolution order: `--config-dir ` launch flag → portable mode (a file named `portable` beside the executable → the executable's directory) → platform default (`%APPDATA%\FileCommand` on Windows; `$XDG_CONFIG_HOME/filecommand` or `~/.config/filecommand` elsewhere). +- The directory is created on first run. If it cannot be created or written, the session runs on in-memory defaults, persistence is skipped, and the existing dismissable startup-warning dialog names the path and error. +- **Legacy notice, no silent migration**: when the profile directory has no `config.toml` and the launch directory contains `config.toml`, `history.json`, or `usermenu.toml`, the startup warning tells the user where FileCommand now reads from and that the old files can be moved. Nothing is copied or deleted. +- `--help` usage gains `--config-dir ` and prints the resolved default profile directory. +- README: the Configuration section is rewritten for the profile directory; the documented `themes/*.toml` directory is dropped from the README because no code loads user theme files (theme-selection lists built-ins only). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: adds the requirement "Configuration profile directory" (resolution order, creation, write-failure fallback, legacy notice, usage output). + +## Impact + +- `crates/filecommand-tui/src/main.rs` — `parse_launch_args` gains `--config-dir`; `print_usage` gains the flag and the resolved default directory line. +- `crates/filecommand-tui/src/app.rs` — a `resolve_profile_dir` resolver (flag → portable marker → platform default) replaces the three bare file names; `Runtime.history_path`/`config_path` and the user-menu path become `profile_dir.join(..)`; `create_dir_all` with warning fallback; legacy-files check feeds `state.startup_warning`. +- `crates/filecommand-core/src/config.rs` — unchanged API (all loaders/savers already take a `&Path`); new pure helper `legacy_files_present(cwd, profile)` for testability. +- README Configuration section; `installer/README.md` note that per-user settings live under `%APPDATA%\FileCommand` (the installer itself ships no config files). +- Non-breaking for code; **behavioral change for users** who relied on per-directory config files (the legacy notice covers it). diff --git a/openspec/changes/config-profile-directory/specs/application-shell/spec.md b/openspec/changes/config-profile-directory/specs/application-shell/spec.md new file mode 100644 index 0000000..8f1bd7f --- /dev/null +++ b/openspec/changes/config-profile-directory/specs/application-shell/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Configuration profile directory + +The system SHALL resolve one profile directory at startup and SHALL read and write `config.toml`, `history.json`, and `usermenu.toml` only there, never relative to the process working directory. Resolution order SHALL be: the `--config-dir ` launch flag; else, when a file named `portable` exists beside the executable, the executable's directory; else the platform default — `%APPDATA%\FileCommand` on Windows and `$XDG_CONFIG_HOME/filecommand` (falling back to `~/.config/filecommand`) elsewhere. The directory SHALL be created on first run. If it cannot be created or written, the session SHALL run on in-memory defaults, SHALL skip persistence, and SHALL raise the dismissable startup-warning dialog naming the path and the error. When the resolved profile holds no `config.toml` and the working directory contains any of the three files, the startup-warning dialog SHALL state where FileCommand now reads its files and that the old files may be moved; the system SHALL NOT copy, move, or delete them. The `--help` usage text SHALL list `--config-dir` and SHALL print the resolved default profile directory. + +#### Scenario: Platform default is used when nothing overrides it +- **WHEN** FileCommand starts on Windows from `C:\Projects\app` with no `--config-dir` flag and no `portable` marker beside the executable +- **THEN** it reads and writes `config.toml`, `history.json`, and `usermenu.toml` under `%APPDATA%\FileCommand`, and no file is created in `C:\Projects\app` + +#### Scenario: Explicit --config-dir override +- **WHEN** FileCommand is launched with `--config-dir D:\fc-test` +- **THEN** every configuration file is read from and written to `D:\fc-test`, and the portable marker and platform default are ignored + +#### Scenario: Portable mode via marker file +- **WHEN** a file named `portable` exists in the executable's directory and no `--config-dir` flag is given +- **THEN** the executable's directory is the profile directory + +#### Scenario: History and frecency are shared across launch directories +- **WHEN** the user visits directories in a session launched from `C:\a`, exits, and relaunches from `C:\b` +- **THEN** the Ctrl+J fuzzy-jump list and command history include the entries recorded in the first session + +#### Scenario: Unwritable profile falls back without losing the session +- **WHEN** the profile directory cannot be created +- **THEN** the application starts with default settings, the startup-warning dialog names the path and error, and theme/split/history changes made during the session are not written to disk + +#### Scenario: Legacy files in the working directory produce a notice, not a migration +- **WHEN** the profile directory has no `config.toml` and the working directory contains `config.toml` and `history.json` +- **THEN** the startup-warning dialog names those files, the working directory, and the profile directory, and both files are left exactly as they were + +#### Scenario: Usage names the profile directory +- **WHEN** `filecommand --help` is run +- **THEN** the usage text lists `--config-dir ` and ends with the resolved default profile directory path diff --git a/openspec/changes/config-profile-directory/tasks.md b/openspec/changes/config-profile-directory/tasks.md new file mode 100644 index 0000000..b8a41a2 --- /dev/null +++ b/openspec/changes/config-profile-directory/tasks.md @@ -0,0 +1,35 @@ +# Tasks: config-profile-directory + +## 1. Launch arguments (`crates/filecommand-tui/src/main.rs`) + +- [ ] 1.1 `parse_launch_args`: accept `--config-dir ` and `--config-dir=`; a missing value is a startup warning, not a crash (application-shell "Configuration profile directory": "Explicit --config-dir override") +- [ ] 1.2 `print_usage`: add the flag line and `Default profile directory: ` (scenario "Usage names the profile directory"; design D4) + +## 2. Profile resolution (`crates/filecommand-tui/src/app.rs`) + +- [ ] 2.1 Add `resolve_profile_dir(flag: Option, exe_dir: Option<&Path>) -> (PathBuf, Option)` implementing D1 (returns an optional warning for the last-resort cwd fallback) +- [ ] 2.2 `create_dir_all`; on failure set `persist_enabled = false` and the D2 warning (scenario "Unwritable profile falls back without losing the session") +- [ ] 2.3 Replace the bare `CONFIG_FILE`/`HISTORY_FILE`/`USERMENU_FILE` paths with `profile.join(..)` for loading and for `Runtime.config_path`/`history_path`/the user-menu path (scenarios "Platform default is used when nothing overrides it", "History and frecency are shared across launch directories") +- [ ] 2.4 Skip `PersistTheme`/`PersistPanelSplit`/`PersistHistory` effects when `persist_enabled` is false + +## 3. Legacy notice (`crates/filecommand-core/src/config.rs`) + +- [ ] 3.1 Add pure `legacy_files_present(cwd: &Path, profile: &Path) -> Vec<&'static str>` (empty when the profile already has `config.toml`) (design D3) +- [ ] 3.2 `app.rs` turns a non-empty result into the D3 `startup_warning` (scenario "Legacy files in the working directory produce a notice, not a migration") + +## 4. Tests + +- [ ] 4.1 `main.rs`: `--config-dir` parsing (both forms; missing value) +- [ ] 4.2 `app.rs`: `resolve_profile_dir` — flag wins; portable marker wins over the platform default; platform default from `APPDATA` (cfg(windows)) / XDG (cfg(not(windows))) +- [ ] 4.3 `config.rs`: `legacy_files_present` with temp dirs — each file individually, none, and profile-already-populated +- [ ] 4.4 Existing config/history/user-menu loader tests pass unchanged (they already take explicit paths) + +## 5. Docs + +- [ ] 5.1 README: rewrite the "Configuration" intro for the profile directory, add `--config-dir` to the flag table, document portable mode and the legacy notice; remove the `themes/*.toml` subsection (no loader exists) and the `themes/` mention in the `theme` key row +- [ ] 5.2 `installer/README.md`: one line stating where per-user settings live + +## 6. Verify + +- [ ] 6.1 `cargo test --workspace` green; `openspec validate config-profile-directory --strict` clean +- [ ] 6.2 Manual: launch from two different directories, change the theme in one, relaunch from the other → theme persists; launch from a directory holding an old `config.toml` → notice shown; `filecommand --help` prints the default path From 9b4c69fb2f38afb1990534ba282a5f7a30c3c527 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:17 -0600 Subject: [PATCH 04/14] Add OpenSpec proposal full-mode-column-widths Render Full-mode Size, Date, and Time values untruncated (9/8/5 cells, sizes right-aligned) by spending the row's slack cells; reserved widths and the column-drop ladder are unchanged so the 80x24 four-column guarantee holds. Co-Authored-By: Claude Fable 5.1 --- .../full-mode-column-widths/.openspec.yaml | 2 + .../changes/full-mode-column-widths/design.md | 42 ++++++++++++ .../full-mode-column-widths/proposal.md | 30 +++++++++ .../specs/panel-navigation/spec.md | 64 +++++++++++++++++++ .../changes/full-mode-column-widths/tasks.md | 23 +++++++ 5 files changed, 161 insertions(+) create mode 100644 openspec/changes/full-mode-column-widths/.openspec.yaml create mode 100644 openspec/changes/full-mode-column-widths/design.md create mode 100644 openspec/changes/full-mode-column-widths/proposal.md create mode 100644 openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md create mode 100644 openspec/changes/full-mode-column-widths/tasks.md diff --git a/openspec/changes/full-mode-column-widths/.openspec.yaml b/openspec/changes/full-mode-column-widths/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/full-mode-column-widths/design.md b/openspec/changes/full-mode-column-widths/design.md new file mode 100644 index 0000000..97e2847 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/design.md @@ -0,0 +1,42 @@ +# Design: full-mode-column-widths + +## Context + +`panel.rs:555-560`: `interior_w = w - 2`; `reserved_w() = SIZE(9)+DATE(8)+TIME(5)+3 = 25`; `name_w = interior_w - marker_w - 25`. `format_full_row` emits `pad(name,name_w) │ ' ' pad(size,7) ' ' pad(date,7) ' ' pad(time,4)` = `name_w + 22` cells, then the header/row is padded to `interior_w` — three cells wasted. At 80×24 with a 50/50 split, `w = 40`, `interior_w = 38`, `name_w = 13` (12 with the git marker column). + +## Goals / Non-Goals + +**Goals:** + +- Every Size/Date/Time value legible in full at every width where its column renders. +- Keep the ladder, `MIN_NAME_W = 12`, and the 80×24 four-column guarantee. +- NC-style right-aligned sizes. + +**Non-Goals:** + +- Changing date/time formats, Brief mode, the mini-status, the git marker column, or scrollbar geometry. + +## Decisions + +### D1: Spend the slack, keep `reserved_w = 25` + +New row: `pad(name, name_w)` + `│` + `rpad(size, 9)` + ` ` + `pad(date, 8)` + ` ` + `pad(time, 5)` = `name_w + 25` = exactly the interior. `ColumnSet::reserved_w` is **not** changed: it keeps returning today's 25 / 20 / 12 / 0 (`SIZE 9 + DATE 8 + TIME 5 + 3`, dropping Time then Date), so `choose_columns`, every breakpoint, and its tests hold. Only `format_full_row` changes: the text widths become `SIZE_TEXT_W = 9`, `DATE_TEXT_W = 8`, `TIME_TEXT_W = 5` and the space after `│` goes away. For the reduced sets the row is shorter than the reserved width (Name+Size+Date: `name_w + 19` of 20; Name+Size: `name_w + 10` of 12) and the remaining cells stay trailing padding, exactly as the slack is handled today. + +Alternative rejected: widen the columns and let the ladder drop Time at 80×24 — it would break the nominal-size guarantee for no gain, since the slack already covers the content. + +### D2: Right-align numeric sizes; directory markers fill the column + +`format_size` yields 4 cells in practice (`1023`, `999G`) and at most 5 (`1024K` after rounding), well within 9; right-aligned under a left-aligned `Size` header reads as it did in NC 5.5. `▶SUB-DIR◀`/`▶UP--DIR◀` are 9 cells and fill the column. Header labels (`Name↓`, `Size`, `Date`, `Time`) stay left-aligned in their cells — the sort arrow logic is untouched. + +### D3: Snapshots are regenerated, not hand-edited + +`cargo insta test --accept` after the change, then review the diff: every Full-mode row should change only in the Size/Date/Time cells and never in the Name cell or the frame. + +## Risks / Trade-offs + +- [Wide snapshot churn in one commit] → Mechanical and reviewable with the D3 rule. +- [Dropping the space after `│` makes `│▶SUB-DIR◀` adjacent] → That is the NC look and it keeps `name_w` intact at 80 columns. + +## Open Questions + +- None. diff --git a/openspec/changes/full-mode-column-widths/proposal.md b/openspec/changes/full-mode-column-widths/proposal.md new file mode 100644 index 0000000..5e34217 --- /dev/null +++ b/openspec/changes/full-mode-column-widths/proposal.md @@ -0,0 +1,30 @@ +# Change: full-mode-column-widths + +## Why + +Full mode renders every Size, Date, and Time value truncated: the renderer reserves 9/8/5 cells per column but pads the text into 7/7/4 (`views/panel.rs` `format_full_row`: `SIZE_COL_W - 2`, `DATE_COL_W - 1`, `TIME_COL_W - 1`), while the values are 9 (`▶SUB-DIR◀`), 8 (`MM-DD-YY`), and 5 (`HH:MM`) cells wide. The README's own screenshots show `▶SUB-DI`, `06-27-2`, `23:3`. The year digit and the minute digit are always cut off, so the user cannot tell 2025 from 2026 or 23:30 from 23:39 without reading the mini-status. Meanwhile each row leaves three interior cells blank at its right edge. + +## What Changes + +- Full-mode rows and the header lay out as `Name│Size Date Time` with the value widths equal to their content: Size 9, Date 8, Time 5, single-space separators after Size and Date, no space after the `│`. Reserved width stays 25, so the Name-column ladder, its 12-cell minimum, and the "all four columns at 80×24" guarantee are unchanged, and the row now fills the interior exactly. +- Numeric sizes are right-aligned within the 9-cell Size column (Norton Commander convention); `▶SUB-DIR◀` and `▶UP--DIR◀` fill it. Header labels keep their current left alignment and sort arrows. +- `MM-DD-YY` and `HH:MM` render in full. +- Brief mode, the mini-status ladder, and the column-drop ladder are untouched. Snapshot tests that contain Full-mode rows are re-accepted. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: "Full display mode layout" gains the exact column widths and the no-truncation guarantee; "Entry row rendering" states that `▶SUB-DIR◀` renders complete and sizes are right-aligned. + +## Impact + +- `crates/filecommand-tui/src/views/panel.rs` — `format_full_row` and the three text-width constants; `choose_columns`/`reserved_w` are untouched (25 / 20 / 12 / 0). +- **Overlap:** `hidden-entry-styling` also carries a MODIFIED "Entry row rendering" delta; whichever lands second re-bases its text on the other's merged requirement. +- `crates/filecommand-tui/tests/snapshot_views.rs` snapshots containing Full-mode rows (about half of the 100 `.snap` files) are regenerated and reviewed. +- README screenshots become stale (text only; optional refresh). +- Non-breaking. diff --git a/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md b/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md new file mode 100644 index 0000000..a5cd82f --- /dev/null +++ b/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md @@ -0,0 +1,64 @@ +## MODIFIED Requirements + +### Requirement: Full display mode layout + +The system SHALL render each panel in Full display mode with a double-line border, the current directory path centered in the top border, a column header row, entry rows, and a mini-status line inside the bottom border. The columns SHALL be the widest set from the ladder `Name+Size+Date+Time → Name+Size+Date → Name+Size → Name` that keeps the Name column at least 12 display cells wide, dropping columns rightmost-first (Time, then Date, then Size) as the panel narrows and restoring them in reverse as it widens; the column header row SHALL show exactly the columns currently rendered. A rendered row SHALL be laid out as the Name cell, a `│` separator, a 9-cell Size value, one space, an 8-cell Date value (`MM-DD-YY`), one space, and a 5-cell Time value (`HH:MM`), so that every rendered Size, Date, and Time value appears untruncated and the row fills the panel interior exactly. At the 80×24 nominal size with the default 50/50 split, all four columns render. The active panel's path title SHALL render inverse (black on cyan) and the inactive panel's title SHALL render cyan on blue. + +#### Scenario: All four columns at the nominal size + +- **WHEN** a panel renders Full mode at terminal size 80×24 with the default 50/50 split +- **THEN** the header row reads `Name | Size | Date | Time` and all four columns render, with the Name column at least 12 display cells wide + +#### Scenario: Values render untruncated at the nominal size + +- **WHEN** a panel renders Full mode at 80×24 with the default split and an entry modified on 2026-06-27 at 23:30 +- **THEN** the row shows `06-27-26` and `23:30` complete, and a directory row shows `▶SUB-DIR◀` complete, with no blank cells between the Time value and the right border + +#### Scenario: Sizes are right-aligned + +- **WHEN** two file entries of 87 bytes and 35 KiB render in Full mode +- **THEN** `87` and `35K` are right-aligned within the Size column so their last digits share a cell column + +#### Scenario: Time drops first on a narrowing panel + +- **WHEN** a panel narrows to where rendering all four columns would leave the Name column under 12 display cells +- **THEN** the Time column and its header are dropped, and the remaining columns render with Name at 12 or more cells + +#### Scenario: Name-only at minimum panel width + +- **WHEN** a panel is at the 20-column minimum width +- **THEN** only the Name column renders, spanning the panel interior, and the header row shows only `Name` + +#### Scenario: Active panel title is inverse + +- **WHEN** a panel is the active panel +- **THEN** its centered path title in the top border SHALL be rendered with the `panel.title.active` role (black on cyan) + +#### Scenario: Inactive panel title is not inverse + +- **WHEN** a panel is not the active panel +- **THEN** its centered path title SHALL be rendered with the `panel.title.inactive` role (cyan on blue) + +#### Scenario: Sort column shows an indicator + +- **WHEN** Full mode renders the column header row and the panel is sorted by Name +- **THEN** the header SHALL show a `↓`/`↑` sort indicator next to the active sort column's label + +### Requirement: Entry row rendering + +The system SHALL render entry rows so directories appear in the bright-white directory style and files in the file style, with `▶UP--DIR◀` shown for the `..` entry and `▶SUB-DIR◀` shown complete in the Size column for directories; numeric sizes SHALL be right-aligned in that column. The entry under the cursor SHALL render as a full-width inverse bar using the `panel.cursor` role. Rendering SHALL use only ANSI-16 named color roles and CP437 box-drawing/geometric glyphs, with no emoji, Nerd Font, or file-type icons. + +#### Scenario: Directory entry styling + +- **WHEN** an entry is a directory +- **THEN** its name SHALL render in the `panel.directory` style and its Size column SHALL read `▶SUB-DIR◀` in full + +#### Scenario: Parent entry styling + +- **WHEN** the entry list contains the parent `..` entry +- **THEN** it SHALL render as `▶UP--DIR◀` in the `panel.directory` style + +#### Scenario: Cursor row is an inverse bar + +- **WHEN** an entry is under the panel cursor +- **THEN** that row SHALL render as a full-width inverse bar using the `panel.cursor` role diff --git a/openspec/changes/full-mode-column-widths/tasks.md b/openspec/changes/full-mode-column-widths/tasks.md new file mode 100644 index 0000000..501421c --- /dev/null +++ b/openspec/changes/full-mode-column-widths/tasks.md @@ -0,0 +1,23 @@ +# Tasks: full-mode-column-widths + +## 1. Renderer (`crates/filecommand-tui/src/views/panel.rs`) + +- [ ] 1.1 Add `SIZE_TEXT_W = 9`, `DATE_TEXT_W = 8`, `TIME_TEXT_W = 5` for the rendered text; leave `ColumnSet::reserved_w` returning today's 25 / 20 / 12 / 0 so `choose_columns` is untouched (design D1) +- [ ] 1.2 `format_full_row`: `│` directly followed by the right-aligned 9-cell size, then ` ` + 8-cell date, then ` ` + 5-cell time (panel-navigation "Full display mode layout": "Values render untruncated at the nominal size"; design D1, D2) +- [ ] 1.3 Add a right-pad helper (or reuse an existing one) for numeric sizes; keep `▶SUB-DIR◀`/`▶UP--DIR◀` left-filled (panel-navigation "Entry row rendering"; design D2) +- [ ] 1.4 Confirm the `choose_columns` unit tests (`choose_columns_drops_time_first` etc.) pass unchanged + +## 2. Tests + +- [ ] 2.1 `panel.rs` unit test: a 38-cell interior row with a directory entry renders `▶SUB-DIR◀`, `06-27-26`, `23:30` complete and fills exactly 38 cells (scenario "Values render untruncated at the nominal size") +- [ ] 2.2 `panel.rs` unit test: file sizes `87` and `35K` are right-aligned in the Size column (scenario "Sizes are right-aligned") +- [ ] 2.3 `cargo insta test --accept`, then review every changed `.snap`: only Size/Date/Time cells differ (design D3) + +## 3. Docs + +- [ ] 3.1 README "Panel display modes": note that sizes are right-aligned; optionally refresh `Screen/*.png` + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green; `openspec validate full-mode-column-widths --strict` clean +- [ ] 4.2 Manual at 80×24 and at a wide terminal, with and without a git repository: all four columns, full dates and times, no trailing blank cells From 9cc4bafd7f60055cd08f35397fc549c47f1aac16 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 05/14] Add OpenSpec proposal line-editing-in-text-fields Introduce a shared LineEdit model (new text-field-editing capability) so every single-line field and the command line get a movable caret, Home/End, word keys, Delete, Ctrl+Backspace, and clipboard paste, with scrolling field rendering; panel key meanings are preserved. Co-Authored-By: Claude Fable 5.1 --- .../.openspec.yaml | 2 + .../line-editing-in-text-fields/design.md | 59 +++++++++++++++++++ .../line-editing-in-text-fields/proposal.md | 35 +++++++++++ .../specs/command-line/spec.md | 53 +++++++++++++++++ .../specs/file-action-menu/spec.md | 30 ++++++++++ .../specs/find-file/spec.md | 25 ++++++++ .../specs/fuzzy-jump/spec.md | 25 ++++++++ .../specs/operation-dialogs/spec.md | 31 ++++++++++ .../specs/text-field-editing/spec.md | 53 +++++++++++++++++ .../line-editing-in-text-fields/tasks.md | 36 +++++++++++ 10 files changed, 349 insertions(+) create mode 100644 openspec/changes/line-editing-in-text-fields/.openspec.yaml create mode 100644 openspec/changes/line-editing-in-text-fields/design.md create mode 100644 openspec/changes/line-editing-in-text-fields/proposal.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md create mode 100644 openspec/changes/line-editing-in-text-fields/tasks.md diff --git a/openspec/changes/line-editing-in-text-fields/.openspec.yaml b/openspec/changes/line-editing-in-text-fields/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/line-editing-in-text-fields/design.md b/openspec/changes/line-editing-in-text-fields/design.md new file mode 100644 index 0000000..983c557 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/design.md @@ -0,0 +1,59 @@ +# Design: line-editing-in-text-fields + +## Context + +Six fields, six copies of "push a char / pop a char": `FileOpInputChar/Backspace` (destination, rename, conflict rename — `update.rs:2948-3060`), `FindFileChar/Backspace`, `FuzzyJumpChar/Backspace`, `CommandLineChar/Backspace`. All state is a `String`. Renderers draw the string left-aligned in a fixed-width bracket field (`destination_input.rs` `FIELD_WIDTH = 40`) and the command line shows the text followed by `_` (responsive-layout already requires "Command line keeps the caret visible"). Over the panels Left/Right are unbound; Home/End/PgUp/PgDn move the panel cursor; Delete requests delete; Ctrl+Left/Right adjust the split; Up/Down recall history only while the line is non-empty. + +## Goals / Non-Goals + +**Goals:** + +- One model, one key map, one renderer helper. +- Classic NC input-line feel. +- No change to any existing panel key meaning. +- Paste from the system clipboard. + +**Non-Goals:** + +- Multi-line editing; selection within a field; mouse caret placement. +- The quick-filter/type-ahead patterns. +- The viewer/editor prompts (they share nothing with dialogs today and are short search patterns). +- Tab completion (separate proposal). + +## Decisions + +### D1: `LineEdit` in core, caret measured in chars + +`pub struct LineEdit { text: String, caret: usize /* char index */ }` with `insert(char)`, `insert_str(&str)`, `backspace()`, `delete()`, `left()`, `right()`, `home()`, `end()`, `word_left()`, `word_right()`, `delete_word_back()`, `delete_word_forward()`, `clear()`, `set_text_caret_end(String)`, `text()`, `caret()`, and `window(width) -> (start_char, caret_col)` for renderers. Word boundaries: runs of alphanumerics vs. anything else, so `C:\Users\kevin` word-jumps at `\`. Char-indexed to keep `unicode-width` out of core logic; renderers map char index → display column with `display_width`. + +Alternative rejected: byte-indexed caret — every operation would need UTF-8 boundary checks; char indexing is simpler and the fields are short. + +### D2: One `TextEditOp` command, routed by phase + +`Command::TextEdit(TextEditOp)` where `TextEditOp = Insert(char) | Backspace | Delete | Left | Right | Home | End | WordLeft | WordRight | DeleteWordBack | DeleteWordForward | Paste(String)`. `update` routes it to whichever field owns the keyboard (destination/rename/conflict-rename input, find-file, fuzzy-jump, else the command line). Existing `FileOpInputChar`/`FindFileChar`/`FuzzyJumpChar`/`CommandLineChar` and their Backspace commands become thin aliases of `TextEdit` (kept so current tests and the quick-search handoff compile unchanged) or are replaced in the same change — implementer's call, but no field may keep a private `String`. + +### D3: Command-line rules preserve every panel key + +Left/Right map to `TextEdit(Left/Right)` only when the line is non-empty (mirrors the Up/Down history rule); on an empty line they stay unbound. Home/End/PgUp/PgDn keep moving the panel cursor even mid-composition (unchanged, documented); Delete keeps opening the delete confirmation (unchanged, documented in the README today); Ctrl+Left/Right stay split keys; Ctrl+Backspace → `DeleteWordBack`. Printables insert at the caret. History recall (`recall_history`) and Ctrl+Enter/Ctrl+] (`paste_cursor_entry`) call `set_text_caret_end`. + +Alternative rejected: Home/End moving the caret while composing — it would silently take two of the most-used panel keys away whenever a single character is on the line. + +### D4: Paste is a TUI-side clipboard read turned into `Paste(String)` + +The key map emits `Command::RequestPaste`; the event loop reads clipboard text (`clipboard-win` `get_clipboard_string` on Windows, `arboard` elsewhere), normalizes CR/LF to a single space, and feeds `TextEdit(Paste(text))`. An unavailable/non-text clipboard is a no-op. Inside dialog fields Shift+Ins and Ctrl+V both map to it (today the unguarded `Char(c)` arm types a literal `v` on Ctrl+V there). Over the panels only Ctrl+V maps to it (unbound today), because the plain `KeyCode::Insert` arm has no modifier guard, so Shift+Ins is already the selection toggle and stays so; Ctrl+Shift+Ins remains clipboard-paths. + +Alternative rejected: Shift+Ins paste over the panels — it would silently take the selection toggle away from anyone who presses Insert with Shift held. + +### D5: Field rendering shows the caret and scrolls + +Each field renderer calls `window(field_w)` and draws `text[start..start+field_w]`, then positions the terminal cursor at the caret column (the frame's `set_cursor_position`; today's dialogs leave the cursor hidden — the plain command line already shows a `_` caret, which is replaced by the terminal cursor at the caret). Pre-filled fields open with the caret at the end so Enter-to-accept is unchanged. + +## Risks / Trade-offs + +- [Replacing `State.command_line: String` touches many tests] → A `text()` accessor and `set_text_caret_end` helper make the assertion changes mechanical. +- [Terminal cursor visibility inside dialogs is new chrome] → It only appears while a text field is focused and is hidden again on close. +- [Ctrl+Backspace on some terminals arrives as `Ctrl+H` or `0x7f`] → Both are mapped to `DeleteWordBack` when the Ctrl modifier is reported, otherwise plain Backspace; best-effort, documented. + +## Open Questions + +- None. diff --git a/openspec/changes/line-editing-in-text-fields/proposal.md b/openspec/changes/line-editing-in-text-fields/proposal.md new file mode 100644 index 0000000..cc3a91f --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/proposal.md @@ -0,0 +1,35 @@ +# Change: line-editing-in-text-fields + +## Why + +Every single-line text field in FileCommand is append-only: the F5/F6/F7 destination and name field, the file-action Rename field, the overwrite-conflict Rename field, the Alt+F7 find pattern, the Ctrl+J fuzzy pattern, and the command line accept only printable characters and Backspace (`input/mod.rs` `map_file_op_setup_key`, `map_file_op_running_key`, and the find/fuzzy/command-line arms). Changing one character in the middle of `report_final_v2.txt` means backspacing through the suffix and retyping it; a long destination path cannot be edited except from its end; nothing can be pasted. Norton Commander's input lines had a movable cursor, Home/End, and insert-at-cursor. This is the single largest everyday friction in the dialogs. + +## What Changes + +- A shared **line-edit model** in `filecommand-core` (`LineEdit { text, caret }`) with insert-at-caret, Backspace, Delete, Left/Right, Home/End, word left/right, delete word back/forward, clear, and paste. Every single-line field above is backed by it. +- **Dialog fields** (destination/mkdir, Rename, conflict Rename, find-file pattern, fuzzy-jump pattern) honor Left/Right, Home/End, Delete, Ctrl+Left/Ctrl+Right, Ctrl+Backspace/Ctrl+Delete, and paste via Shift+Ins or Ctrl+V. A pre-filled field opens with the caret at its end. +- **Command line**: printable keys insert at the caret; Left/Right move the caret while the line is non-empty (on an empty line they have no command-line meaning, exactly like Up/Down today); Ctrl+Backspace deletes the word before the caret; Ctrl+V pastes. Home/End, PgUp/PgDn, Delete, Shift+Ins, and Ctrl+Left/Right keep their panel meanings (cursor jump, delete-file confirmation, selection toggle, split adjust). History recall and Ctrl+Enter/Ctrl+] paste leave the caret at the end. +- **Rendering**: each field shows the terminal cursor at the caret and scrolls its visible window horizontally so the caret is always visible; the bracket-and-dots field style is unchanged. +- Out of scope: the quick-filter and type-ahead patterns (search-as-you-type, not editable lines), the viewer F7 and editor F4/F7 prompts, mouse caret placement, Tab completion. + +## Capabilities + +### New Capabilities + +- `text-field-editing`: the caret model, the key set, paste, and the scrolling field rendering shared by every single-line text field. + +### Modified Capabilities + +- `command-line`: "Command-line prompt and printable-key routing" gains caret insertion and the non-empty-line Left/Right rule; "Paste filename and path to command line" states the caret lands at the end. +- `operation-dialogs`: "Destination input dialog" references the shared editing keys. +- `file-action-menu`: "In-place Rename" opens with the caret at the end of the pre-filled name and honors the shared keys. +- `find-file`: "Find-file invocation" — the pattern field is a shared text field. +- `fuzzy-jump`: "Fuzzy jump dialog invocation" — the pattern field is a shared text field. + +## Impact + +- `crates/filecommand-core/src/line_edit.rs` (new) — model + unit tests; `fs_ops/dialog.rs` (`DestinationInput.input`, `RenameInput.input`, conflict `rename_input`), `find_file.rs` (`pattern`), `quicksearch.rs` (`FuzzyJumpState.pattern`), `update.rs` (`State.command_line` becomes a `LineEdit`; new `Command::TextEdit(TextEditOp)` routed by phase; `Command::RequestPaste` and `Effect::ReadClipboardText`). +- `crates/filecommand-tui/src/input/mod.rs` — one `map_text_edit_key` helper used by every field; command-line arms for Left/Right/Ctrl+Backspace/paste. `app.rs` — clipboard **read** (text) via the existing `clipboard-win`/`arboard` dependencies; terminal cursor placement while a field is focused. +- `crates/filecommand-tui/src/views/{destination_input,find_file,fuzzy_jump,command_line}.rs` — caret placement and horizontal window. +- Tests: `update/tests.rs`, `input/tests.rs`, snapshot additions for a scrolled field. +- Non-breaking for existing key meanings over the panels (Left/Right were unbound there). diff --git a/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md b/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md new file mode 100644 index 0000000..77d8909 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md @@ -0,0 +1,53 @@ +## MODIFIED Requirements + +### Requirement: Command-line prompt and printable-key routing + +The system SHALL render a command line below the panels showing a shell prompt with the active panel's current path (e.g. `C:\NORTON>`), and while a panel is focused and no quick-search or dialog is active, printable keys SHALL be inserted into the command-line buffer at its caret rather than sent to the panel, matching classic NC behavior. The buffer SHALL be backed by the shared line-edit model (`text-field-editing`). While the buffer is non-empty, Left and Right SHALL move the caret and Ctrl+Backspace SHALL delete the word before it; while the buffer is empty, Left and Right SHALL have no command-line meaning. Home, End, PgUp, PgDn, Delete, and Ctrl+Left/Ctrl+Right SHALL keep their panel meanings regardless of buffer content. + +The prompt SHALL follow the active panel: switching panels or navigating the active panel to a new directory SHALL update the displayed prompt path to that panel's current path. + +Because printable keys route to the command line while a panel is focused, the command line is a distinct typing sink from the §4.7 type-ahead quick-search: when quick-search mode is active it consumes plain printable keys, and only one sink SHALL consume any given key. + +#### Scenario: Prompt shows active panel path +- **WHEN** the left panel is active and its current directory is `C:\NORTON` +- **THEN** the command line renders the prompt `C:\NORTON>` followed by the (empty) command buffer + +#### Scenario: Prompt updates on panel switch +- **WHEN** the right panel's directory is `D:\WORK` and the user presses Tab to make the right panel active +- **THEN** the command-line prompt updates to `D:\WORK>` + +#### Scenario: Printable key routes to command line +- **WHEN** a panel is focused, no quick-search or dialog is active, and the user types `d`, `i`, `r` +- **THEN** the command-line buffer becomes `dir` and the panel cursor does not move + +#### Scenario: Caret editing mid-line +- **WHEN** the buffer holds `cd srv` and the user presses Left, Left, then types `c` +- **THEN** the buffer reads `cd scrv` with the caret after the `c` and the panel cursor does not move + +#### Scenario: Left on an empty line does nothing to the panel +- **WHEN** the buffer is empty and the user presses Left +- **THEN** neither the buffer nor the panel cursor changes + +#### Scenario: Home still moves the panel cursor while composing +- **WHEN** the buffer holds `cd docs` and the user presses Home +- **THEN** the panel cursor jumps to the first entry and the buffer and its caret are unchanged + +#### Scenario: Quick-search mode captures printables instead +- **WHEN** type-ahead quick-search mode is active and the user types a printable key +- **THEN** the key extends the quick-search pattern and the command-line buffer is left unchanged + +### Requirement: Paste filename and path to command line + +The system SHALL paste the cursor entry's filename onto the command line when Ctrl+Enter is pressed, and the cursor entry's full path when Ctrl+] is pressed, appending to the end of the command-line buffer (after a separating space when the buffer is non-empty and does not already end in one) and leaving the caret at the end. Ctrl+] (ASCII 0x1D) SHALL be available on all platforms; Ctrl+Enter SHALL be available on Windows and best-effort elsewhere (available only where the kitty keyboard protocol delivers it). Both bindings SHALL be overridable in `config.toml`. + +#### Scenario: Ctrl+Enter pastes filename +- **WHEN** the cursor is on entry `README.md` in `C:\NORTON` and the user presses Ctrl+Enter +- **THEN** `README.md` is inserted at the end of the command-line buffer and the caret is at the end + +#### Scenario: Ctrl+] pastes full path +- **WHEN** the cursor is on entry `README.md` in `C:\NORTON` and the user presses Ctrl+] +- **THEN** `C:\NORTON\README.md` is inserted at the end of the command-line buffer and the caret is at the end + +#### Scenario: Paste after caret editing lands at the end +- **WHEN** the buffer holds `del ` with the caret after `d`, and the user presses Ctrl+Enter on `old.log` +- **THEN** the buffer reads `del old.log` and the caret is at the end diff --git a/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md b/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md new file mode 100644 index 0000000..8844811 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: In-place Rename + +The Rename menu entry SHALL open an input dialog pre-filled with the target entry's current name, with the caret at the end of the field; the field SHALL be a shared text field per `text-field-editing`. Accepting the dialog SHALL rename the entry within its current directory using the existing same-volume rename machinery, including identity-aware case-only rename. Cancelling with Esc SHALL rename nothing. A rename that collides with an existing name or fails SHALL surface the existing overwrite-conflict or error-recovery dialogs from `operation-dialogs`. On successful rename the affected panel SHALL be re-read automatically. + +#### Scenario: Rename pre-fills the current name + +- **WHEN** the user activates Rename for `draft.txt` +- **THEN** an input dialog opens pre-filled with `draft.txt` and the caret at the end of the field + +#### Scenario: Editing the stem without retyping the extension + +- **WHEN** the rename dialog for `draft.txt` is open and the user presses Home, then Ctrl+Right, then types `-v2` +- **THEN** the field reads `draft-v2.txt` + +#### Scenario: Accepting renames in place + +- **WHEN** the rename dialog for `draft.txt` is accepted with the value `final.txt` +- **THEN** `draft.txt` is renamed to `final.txt` in the same directory and the panel is re-read + +#### Scenario: Case-only rename works + +- **WHEN** the rename dialog for `readme.md` is accepted with the value `README.md` +- **THEN** the entry's name case changes to `README.md` using the identity-aware case-only rename path + +#### Scenario: Esc renames nothing + +- **WHEN** the rename dialog is open and the user presses Esc +- **THEN** the dialog closes and the entry keeps its original name diff --git a/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md b/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md new file mode 100644 index 0000000..2580dac --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Find-file invocation + +The system SHALL open a find-file dialog when the user presses Alt+F7 or selects Commands → Find file, presenting an input field for the name pattern to search for. The field SHALL be a shared text field per `text-field-editing`. The dialog SHALL render in the primary dialog style (black text on cyan, black double-line frame) with a bracket-and-dots input field per §4.4, using only §4.11 ANSI-16 roles and CP437-heritage single-cell glyphs. + +#### Scenario: Alt+F7 opens the dialog + +- **WHEN** a panel is focused and the user presses Alt+F7 +- **THEN** the find-file dialog opens centered over the panels with an empty, focused name-pattern input field + +#### Scenario: Menu entry opens the dialog + +- **WHEN** the user selects Commands → Find file from the F9 pull-down menu +- **THEN** the same find-file dialog opens, identically to the Alt+F7 path + +#### Scenario: Pattern is editable mid-string + +- **WHEN** the pattern field reads `reprt` and the user presses Left, Left, then types `o` +- **THEN** the field reads `report` + +#### Scenario: Dialog uses the primary style and permitted glyphs + +- **WHEN** the find-file dialog is rendered +- **THEN** it uses the `dialog.primary` and `dialog.input` roles with no icons, emoji, or non-CP437 glyphs diff --git a/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md b/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md new file mode 100644 index 0000000..4996559 --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Fuzzy jump dialog invocation + +The system SHALL open the fuzzy jump dialog when the user presses Ctrl+J (the default binding, overridable via `config.toml`), presenting a modal list of previously visited directories drawn in the standard NC dialog style over the panels. The dialog SHALL provide a single-line input field — a shared text field per `text-field-editing` — into which the user types a fuzzy match pattern, and Esc SHALL close the dialog without changing the active panel's directory. + +#### Scenario: Ctrl+J opens the dialog + +- **WHEN** the user presses Ctrl+J while a panel is focused +- **THEN** the fuzzy jump dialog opens as a modal box over the panels with an empty input field and the visited-directory list shown beneath it + +#### Scenario: Esc closes without navigating + +- **WHEN** the fuzzy jump dialog is open and the user presses Esc +- **THEN** the dialog closes and the active panel remains at its current directory unchanged + +#### Scenario: Pattern edited mid-string re-narrows the list + +- **WHEN** the pattern reads `prj` and the user presses Left, then types `o` +- **THEN** the pattern reads `proj` and the list is re-narrowed against `proj` + +#### Scenario: Overridden binding still opens the dialog + +- **WHEN** `config.toml` rebinds fuzzy jump to a different key and the user presses that key +- **THEN** the fuzzy jump dialog opens exactly as it would for the default Ctrl+J binding diff --git a/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md b/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md new file mode 100644 index 0000000..dadf9be --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Destination input dialog + +The system SHALL present, when the user starts a Copy (F5) or Rename/Move (F6) operation, a primary-style input dialog whose editable destination field is pre-filled with the opposite panel's current path, so the common "copy to the other panel" case requires no typing. The field SHALL be a shared text field per `text-field-editing` (caret keys, word keys, paste, scrolling window), opening with the caret at the end of the pre-filled path. + +#### Scenario: Destination pre-filled from the opposite panel + +- **WHEN** the user presses F5 with the left panel active and the right panel showing `D:\backup` +- **THEN** the destination input dialog opens with its field pre-filled with `D:\backup` +- **AND** the caret is at the end of the field so the user can edit or accept it + +#### Scenario: Editing the pre-filled destination in place + +- **WHEN** the field reads `D:\backup` and the user presses Home, Delete, then types `E` +- **THEN** the field reads `E:\backup` + +#### Scenario: Accepting the pre-filled destination starts the job + +- **WHEN** the destination input dialog is open with a valid destination and the user activates the default button with Enter +- **THEN** the dialog closes and the operation begins against the entered destination + +#### Scenario: Cancelling the destination dialog aborts the operation + +- **WHEN** the destination input dialog is open and the user presses Esc +- **THEN** the dialog closes and no file operation is started + +#### Scenario: Manual UNC destination is accepted + +- **WHEN** the user clears the field and types a UNC destination such as `\\server\share\dest` +- **THEN** the entered UNC path is used as the operation destination diff --git a/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md b/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md new file mode 100644 index 0000000..d2f6cec --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Caret model and editing keys + +Every single-line text field — the Copy/Move destination and Make-directory name field, the file-action Rename field, the overwrite-conflict Rename field, the find-file pattern, the fuzzy-jump pattern, and the command line — SHALL be backed by one shared line-edit model holding the text and a caret position. Printable characters SHALL be inserted at the caret; Backspace SHALL delete the character before the caret and Delete the character after it; Left/Right SHALL move the caret by one character; Home/End SHALL move it to the start/end; Ctrl+Left/Ctrl+Right SHALL move it by one word; Ctrl+Backspace/Ctrl+Delete SHALL delete the word before/after the caret. A field that opens pre-filled SHALL open with the caret at the end of the text. The command line SHALL apply these keys subject to the exceptions in `command-line` "Command-line prompt and printable-key routing" (keys that keep their panel meaning). The model SHALL live in `filecommand-core` with all mutations flowing through `core::update`. + +#### Scenario: Insert in the middle of a pre-filled name +- **WHEN** the Rename field opens pre-filled with `draft.txt`, and the user presses Left five times, Backspace, then types `i` +- **THEN** the field reads `drift.txt` with the caret after the `i` + +#### Scenario: Home then edit a destination prefix +- **WHEN** the destination field holds `D:\backup\2026`, and the user presses Home, Delete twice, then types `\\server\share` +- **THEN** the field reads `\\server\share\backup\2026` + +#### Scenario: Word delete +- **WHEN** the field holds `C:\Users\kevin\docs` with the caret at the end and the user presses Ctrl+Backspace +- **THEN** the field reads `C:\Users\kevin\` + +#### Scenario: Pre-filled field accepts unchanged with Enter +- **WHEN** the destination field opens pre-filled with the opposite panel's path and the user presses Enter without any editing key +- **THEN** the operation starts against that path exactly as before this capability existed + +### Requirement: Paste into a text field + +The system SHALL paste the system clipboard's text at the caret of the focused dialog text field on Shift+Ins or Ctrl+V, with any line breaks replaced by a single space. When the clipboard holds no text, the key SHALL have no effect. Over the panels, Ctrl+V SHALL paste into the command line, while Shift+Ins SHALL keep its selection-toggle meaning and Ctrl+Shift+Ins SHALL remain the clipboard-paths action. + +#### Scenario: Paste a path into the destination field +- **WHEN** the clipboard holds `E:\archive` and the destination field is focused with the caret at the end of `D:\` +- **THEN** after Ctrl+V the field reads `D:\E:\archive` with the caret at the end + +#### Scenario: Multi-line clipboard text is flattened +- **WHEN** the clipboard holds two lines and the user presses Ctrl+V over the panels +- **THEN** the two lines are inserted into the command line joined by one space, and the panel cursor does not move + +#### Scenario: Shift+Ins over the panels still toggles selection +- **WHEN** the clipboard holds text and the user presses Shift+Ins with the panels focused +- **THEN** the cursor entry's selection is toggled and nothing is pasted + +#### Scenario: Empty clipboard is a no-op +- **WHEN** the clipboard holds no text and the user presses Ctrl+V in a focused field +- **THEN** the field and caret are unchanged + +### Requirement: Field rendering keeps the caret visible + +Each text field SHALL render a window of its text sized to the field width, scrolled so the caret is always within the window, and SHALL show the terminal cursor at the caret's screen position while the field is focused. The bracket-and-dots field style, dialog geometry, and roles SHALL be unchanged. + +#### Scenario: Long destination scrolls to the caret +- **WHEN** the destination field (40 cells) holds a 60-character path and the caret is at the end +- **THEN** the field shows the last 40 characters and the terminal cursor sits at the field's last cell + +#### Scenario: Caret moved left scrolls the window back +- **WHEN** the field above is focused and the user presses Home +- **THEN** the field shows the first 40 characters and the terminal cursor sits at the field's first cell diff --git a/openspec/changes/line-editing-in-text-fields/tasks.md b/openspec/changes/line-editing-in-text-fields/tasks.md new file mode 100644 index 0000000..8a8340d --- /dev/null +++ b/openspec/changes/line-editing-in-text-fields/tasks.md @@ -0,0 +1,36 @@ +# Tasks: line-editing-in-text-fields + +## 1. Core model (`crates/filecommand-core/src/line_edit.rs`, new) + +- [ ] 1.1 `LineEdit` per design D1 with all operations and `window(width)` +- [ ] 1.2 Unit tests: insert mid-line, backspace/delete at ends, word jumps across `\` and spaces, delete-word, paste with CR/LF normalized, `window` keeps the caret visible for long text + +## 2. State and reducer (`crates/filecommand-core`) + +- [ ] 2.1 `fs_ops/dialog.rs`: `DestinationInput.input`, `RenameInput.input`, conflict `rename_input` become `LineEdit` (pre-filled → caret at end) (text-field-editing "Caret model and editing keys"; file-action-menu "In-place Rename") +- [ ] 2.2 `find_file.rs` `pattern`, `quicksearch.rs` `FuzzyJumpState.pattern` become `LineEdit`; search-narrowing reads `text()` +- [ ] 2.3 `update.rs`: `State.command_line: LineEdit`; `Command::TextEdit(TextEditOp)` routed by phase (design D2); `recall_history`, `paste_cursor_entry`, `CommandLineClear`, `run_command_line` use `text()`/`set_text_caret_end`/`clear` (command-line "Paste filename and path to command line": caret at end) +- [ ] 2.4 `Command::RequestPaste` → `Effect::ReadClipboardText`; `TextEdit(Paste)` inserts at the caret (text-field-editing "Paste into a text field"; design D4) +- [ ] 2.5 Tests in `update/tests.rs`: destination field edit in the middle; rename `draft.txt` → `drift.txt` via Left×5, Backspace, `i`; command line Left/Right only when non-empty; Home still moves the panel cursor mid-composition; history recall puts the caret at the end; paste normalizes newlines + +## 3. Input mapping (`crates/filecommand-tui/src/input/mod.rs`) + +- [ ] 3.1 `map_text_edit_key(key) -> Option` covering Left/Right/Home/End/Delete/Backspace/Ctrl+Left/Ctrl+Right/Ctrl+Backspace/Ctrl+Delete/Shift+Ins/Ctrl+V/printables; used by the destination, rename, conflict-rename, find-file, and fuzzy-jump arms +- [ ] 3.2 Panel arm: Left/Right → caret moves when `typing`; Ctrl+Backspace → `DeleteWordBack`; Ctrl+V → `RequestPaste`; Home/End/Delete/Shift+Ins/Ctrl+Left/Right unchanged (command-line "Command-line prompt and printable-key routing"; design D3, D4) +- [ ] 3.3 `input/tests.rs`: each mapping above, plus "Left on an empty line maps to nothing", "Shift+Ins over the panels still toggles selection", and "Ctrl+Shift+Ins is still clipboard paths" + +## 4. Rendering (`crates/filecommand-tui/src/views/*`) + +- [ ] 4.1 Shared `draw_text_field(buf, x, y, width, &LineEdit, style) -> caret_x` using `window()`; used by `destination_input.rs`, `find_file.rs`, `fuzzy_jump.rs` (text-field-editing "Field rendering keeps the caret visible"; design D5) +- [ ] 4.2 `views/command_line.rs`: window around the caret, terminal cursor at the caret replacing the drawn `_` (responsive-layout "Command line keeps the caret visible" still holds) +- [ ] 4.3 `app.rs`: set the terminal cursor position after drawing when a text field is focused; hide it otherwise; implement `Effect::ReadClipboardText` with `clipboard-win`/`arboard` +- [ ] 4.4 Snapshot tests: a destination field longer than 40 cells scrolled so the caret is visible; command line with the caret mid-text + +## 5. Docs + +- [ ] 5.1 README "Command line → Typing" table and "Keyboard reference": caret keys, Ctrl+Backspace, paste; F1 Help "Command line" and "File operations" pages + +## 6. Verify + +- [ ] 6.1 `cargo test --workspace` green; `openspec validate line-editing-in-text-fields --strict` clean +- [ ] 6.2 Manual: F6 on a file, Home, type a prefix, Enter; F5, End, Backspace×3, type, Enter; command line `cd ` + Shift+Ins of a copied path; Ctrl+J pattern edited mid-string From fcd096bb83a7316994f96bc5d7f74eb348d24beb Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 06/14] Add OpenSpec proposal command-line-clear-key Bind Ctrl+Y (rebindable via key.clear_line) to the existing CommandLineClear command over the panels, ending any history recall; Esc keeps requesting quit. Co-Authored-By: Claude Fable 5.1 --- .../command-line-clear-key/.openspec.yaml | 2 + .../changes/command-line-clear-key/design.md | 43 +++++++++++++++++ .../command-line-clear-key/proposal.md | 29 ++++++++++++ .../specs/command-line/spec.md | 47 +++++++++++++++++++ .../changes/command-line-clear-key/tasks.md | 25 ++++++++++ 5 files changed, 146 insertions(+) create mode 100644 openspec/changes/command-line-clear-key/.openspec.yaml create mode 100644 openspec/changes/command-line-clear-key/design.md create mode 100644 openspec/changes/command-line-clear-key/proposal.md create mode 100644 openspec/changes/command-line-clear-key/specs/command-line/spec.md create mode 100644 openspec/changes/command-line-clear-key/tasks.md diff --git a/openspec/changes/command-line-clear-key/.openspec.yaml b/openspec/changes/command-line-clear-key/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/command-line-clear-key/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/command-line-clear-key/design.md b/openspec/changes/command-line-clear-key/design.md new file mode 100644 index 0000000..4773a7a --- /dev/null +++ b/openspec/changes/command-line-clear-key/design.md @@ -0,0 +1,43 @@ +# Design: command-line-clear-key + +## Context + +`update.rs:1349` — `Command::CommandLineClear` clears `command_line` and `history_cursor`; nothing outside tests emits it today (`run_command_line` clears the buffer directly after Enter). `Keys` (`config.rs:67-118`) already models overridable chords with `KeyBinding::new(ctrl, alt, shift, key)` and `matches_binding` in `input/mod.rs`. Ctrl+Y is unbound over the panels (the `Char(c)` arm requires `is_plain`). The quit-confirm, file-op DeleteConfirm, and editor quit-confirm arms match `Char('y')` without a modifier guard, so Ctrl+Y currently acts as a plain `y` there, and the destination/rename fields type a literal `y`; those keymaps are not touched by this change. + +## Goals / Non-Goals + +**Goals:** + +- One obvious key to abandon a typed line. +- Keep the user's Esc-quits decision. +- Follow the `key.*` convention. + +**Non-Goals:** + +- Changing Esc. +- Clearing the quick filter or type-ahead pattern (they have their own exits). +- A clear key inside dialog fields (Home + Ctrl+Delete covers it once `line-editing-in-text-fields` lands). + +## Decisions + +### D1: Ctrl+Y, fixed meaning, rebindable chord + +Ctrl+Y is free on every host terminal crossterm supports and does not collide with Ctrl+U, which `pulldown-menu-completion` assigns to Swap panels (NC's own Ctrl+U). `key.clear_line = "ctrl+y"` is the default. + +Alternative rejected: Ctrl+U (Unix line-kill) — it is NC's Swap panels chord and would collide with that proposal. + +### D2: No-op on an empty line + +The chord only emits `CommandLineClear` when `typing` is true; on an empty line it falls through to nothing, so a mistaken press never does anything surprising. + +### D3: Reuse `CommandLineClear` verbatim + +No new command. Its existing behavior (clear text + reset `history_cursor`) is exactly "abandon the line, including a half-recalled history entry". With `line-editing-in-text-fields` applied, the same command clears the `LineEdit` and resets the caret. + +## Risks / Trade-offs + +- [Users of Unix-style Ctrl+U may expect it to clear] → The README notes the rebinding; `key.clear_line = "ctrl+u"` would then shadow Swap panels, documented as the user's choice. + +## Open Questions + +- None. diff --git a/openspec/changes/command-line-clear-key/proposal.md b/openspec/changes/command-line-clear-key/proposal.md new file mode 100644 index 0000000..859f871 --- /dev/null +++ b/openspec/changes/command-line-clear-key/proposal.md @@ -0,0 +1,29 @@ +# Change: command-line-clear-key + +## Why + +Since `quit-keys`, Esc over the panels requests quit instead of clearing the command line, and the spec deliberately says so ("Esc SHALL NOT clear the buffer"). The only way to discard a typed line is to Backspace through it. `Command::CommandLineClear` already exists in `core::update` (it clears the text and resets the history cursor) but no key emits it. A single chord fixes this without reopening the Esc decision. + +## What Changes + +- **Ctrl+Y** clears the command line while the panels own input; it also ends any history recall in progress. On an empty line it does nothing. Rebindable via `key.clear_line` in `config.toml`, following the existing `key.*` override mechanism. +- Esc behavior is unchanged (still requests quit). The chord is bound only over the panels; the keymaps of the built-in editor, viewer, menus, and dialogs are not touched (today their unguarded `y` arms treat Ctrl+Y as a plain `y`, which stays as is). +- README and F1 Help list the key. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `command-line`: "Command history navigation" names the clear key as the second way (besides Backspace) to hand Up/Down back to the panel; a new requirement "Clear the command line with a dedicated key" defines the binding, its no-op on an empty line, and its rebinding. + +## Impact + +- `crates/filecommand-core/src/config.rs` — `Keys.clear_line` (default Ctrl+Y), parsed from `key.clear_line`. +- `crates/filecommand-tui/src/input/mod.rs` — panel arm emitting `Command::CommandLineClear` when the chord matches and the buffer is non-empty. +- `crates/filecommand-tui/src/app.rs` `apply_config` wiring; README key tables; F1 Help "Command line" page. +- Tests: `input/tests.rs`, `config.rs` parsing, one `update/tests.rs` assertion that `CommandLineClear` also drops `history_cursor` (already true). +- Non-breaking; additive. diff --git a/openspec/changes/command-line-clear-key/specs/command-line/spec.md b/openspec/changes/command-line-clear-key/specs/command-line/spec.md new file mode 100644 index 0000000..3eff090 --- /dev/null +++ b/openspec/changes/command-line-clear-key/specs/command-line/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Clear the command line with a dedicated key + +The system SHALL clear the entire command-line buffer when the user presses the clear-line chord (default Ctrl+Y) while the panels own input and the buffer is non-empty; clearing SHALL also end any history recall in progress so that Up/Down return to moving the panel cursor. On an empty buffer the chord SHALL have no effect. The chord SHALL be overridable via `key.clear_line` in `config.toml` using the existing keybinding-override mechanism. Esc SHALL continue to request quit (application-shell "Quit request keys and confirmation"); the chord SHALL be bound only while the panels own input, and the existing key handling of menus, dialogs, the viewer, and the editor SHALL be unchanged. + +#### Scenario: Ctrl+Y clears a composed line +- **WHEN** the buffer holds `cd Program Files` and the user presses Ctrl+Y +- **THEN** the buffer is empty, the panel cursor has not moved, and no dialog opens + +#### Scenario: Ctrl+Y on an empty line does nothing +- **WHEN** the buffer is empty and the user presses Ctrl+Y +- **THEN** nothing changes: no quit dialog, no cursor movement + +#### Scenario: Clearing ends history recall +- **WHEN** the user has recalled a history entry with Up and then presses Ctrl+Y, then Up again +- **THEN** the buffer is empty after Ctrl+Y and the second Up moves the panel cursor instead of recalling history + +#### Scenario: Rebinding the clear key +- **WHEN** `config.toml` sets `key.clear_line = "ctrl+k"` and the buffer holds text +- **THEN** Ctrl+K clears the buffer and Ctrl+Y no longer does + +## MODIFIED Requirements + +### Requirement: Command history navigation + +The system SHALL maintain a command history and, while the command-line buffer is non-empty, Up and Down SHALL navigate previous and next history entries into the buffer. While the buffer is empty, Up and Down SHALL instead move the panel cursor. Backspacing the buffer to empty, or pressing the clear-line chord (default Ctrl+Y — see "Clear the command line with a dedicated key"), SHALL be the mechanisms that hand Up/Down back to the panel; Esc SHALL NOT clear the buffer — over the panels it requests application quit (application-shell "Quit request keys and confirmation"). Command history SHALL persist to `history.json`, written atomically. + +#### Scenario: Up recalls previous command while composing + +- **WHEN** the command buffer contains text and the user presses Up +- **THEN** the buffer is replaced with the previous history entry and the panel cursor does not move + +#### Scenario: Backspacing to empty releases Up/Down to panel + +- **WHEN** the command buffer is non-empty and the user backspaces until it is empty, then presses Up +- **THEN** the subsequent Up moves the panel cursor and recalls no history entry + +#### Scenario: Esc does not clear the buffer + +- **WHEN** the command buffer is non-empty and the user presses Esc +- **THEN** the buffer is not cleared and the quit-confirmation dialog opens instead + +#### Scenario: Executed command persisted to history + +- **WHEN** the user runs a command with Enter +- **THEN** the command is appended to the history and `history.json` is written atomically diff --git a/openspec/changes/command-line-clear-key/tasks.md b/openspec/changes/command-line-clear-key/tasks.md new file mode 100644 index 0000000..abcc6f3 --- /dev/null +++ b/openspec/changes/command-line-clear-key/tasks.md @@ -0,0 +1,25 @@ +# Tasks: command-line-clear-key + +## 1. Config (`crates/filecommand-core/src/config.rs`) + +- [ ] 1.1 `Keys.clear_line: KeyBinding`, default `ctrl+y`; parse `key.clear_line` (command-line "Clear the command line with a dedicated key": "Rebinding the clear key"; design D1) +- [ ] 1.2 Parser tests for the override and for the default + +## 2. Input (`crates/filecommand-tui/src/input/mod.rs`) + +- [ ] 2.1 Panel arm: when `typing` and the key matches `keys.clear_line`, emit `Command::CommandLineClear`; empty line → no command (scenarios "Ctrl+Y clears a composed line", "Ctrl+Y on an empty line does nothing"; design D2, D3) +- [ ] 2.2 `app.rs` `apply_config`: wire `keys.clear_line` like the other overridable chords +- [ ] 2.3 `input/tests.rs`: both scenarios; `CommandLineClear` is never emitted while a dialog, menu, viewer, or editor owns input (their existing handling is unchanged) + +## 3. Reducer test (`crates/filecommand-core/src/update/tests.rs`) + +- [ ] 3.1 Recall a history entry with Up, send `CommandLineClear` → buffer empty, `history_cursor` is `None`, next Up moves the panel cursor (scenario "Clearing ends history recall") + +## 4. Docs + +- [ ] 4.1 README: "Typing" table row for Ctrl+Y, `key.clear_line` in the config key list and the overridable-bindings sentence; F1 Help "Command line" page + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate command-line-clear-key --strict` clean +- [ ] 5.2 Manual: type text, Ctrl+Y → empty; Up → panel cursor moves; rebind to `ctrl+k` in `config.toml` → Ctrl+K clears From 1f4b6bdd8a96799260ffc0a22b1c4c1ef0e536c6 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:18 -0600 Subject: [PATCH 07/14] Add OpenSpec proposal hidden-entry-styling Carry the hidden/system attribute on listing entries and render them with a new panel.hidden theme role in Full and Brief modes, with drag > cursor > selected > hidden > file/directory precedence; no show/hide toggle. Co-Authored-By: Claude Fable 5.1 --- .../hidden-entry-styling/.openspec.yaml | 2 + .../changes/hidden-entry-styling/design.md | 54 +++++++++++++++++++ .../changes/hidden-entry-styling/proposal.md | 32 +++++++++++ .../specs/additional-panel-modes/spec.md | 35 ++++++++++++ .../specs/panel-navigation/spec.md | 35 ++++++++++++ .../specs/theme-system/spec.md | 17 ++++++ .../changes/hidden-entry-styling/tasks.md | 26 +++++++++ 7 files changed, 201 insertions(+) create mode 100644 openspec/changes/hidden-entry-styling/.openspec.yaml create mode 100644 openspec/changes/hidden-entry-styling/design.md create mode 100644 openspec/changes/hidden-entry-styling/proposal.md create mode 100644 openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md create mode 100644 openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md create mode 100644 openspec/changes/hidden-entry-styling/specs/theme-system/spec.md create mode 100644 openspec/changes/hidden-entry-styling/tasks.md diff --git a/openspec/changes/hidden-entry-styling/.openspec.yaml b/openspec/changes/hidden-entry-styling/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/hidden-entry-styling/design.md b/openspec/changes/hidden-entry-styling/design.md new file mode 100644 index 0000000..3a57136 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/design.md @@ -0,0 +1,54 @@ +# Design: hidden-entry-styling + +## Context + +`listing.rs:438-451` builds `RawDirEntry{name,is_dir,size,modified}` from `entry.metadata()`; converting to `Entry` at `:455`. `theme.rs` roles are an enum + `ALL_ROLES`; `Theme::build` rejects a missing role. `panel.rs` chooses a row style in Full mode as drag target → cursor → directory/file (`:596-604`), with the selected style applied by the name-cell styling; the Brief renderer (`:709-719`) consults the selection inline in the same drag → cursor → selected → directory/file order. + +## Goals / Non-Goals + +**Goals:** + +- Hidden/system entries recognizable at a glance in every theme. +- Zero behavioral change beyond color. +- Attribute read from the enumeration (no per-file stat). + +**Non-Goals:** + +- A show/hide toggle. +- Attribute letters in the mini-status. +- Tree mode and Quick view styling. +- An Attributes dialog. + +## Decisions + +### D1: `hidden` is computed in the reader, once + +Windows: `metadata.file_attributes() & (0x2 | 0x4) != 0`. Non-Windows: `name` begins with `.`. Stored on `RawDirEntry` and copied to `Entry`, so sorting/filtering code never recomputes it. The fake readers used by tests gain the field with a default of `false`. + +Alternative rejected: computing it lazily in the renderer from the name — the renderer has no attribute access, and the dot-prefix rule is wrong on Windows. + +### D2: One new role, dimmer than `panel.file` on the same background + +| Theme | `panel.hidden` ANSI fg / bg | Truecolor fg (bg as the theme's file bg) | +|---|---|---| +| nc-classic | bright-black / blue | none | +| nc-mono | bright-black / black | none | +| terminal-green | bright-black / black | `#1E7A1E` | +| purple-lights | blue / magenta | `#6E5A80` on `#300040` | +| yellow-storm | bright-black / black | `#806000` | +| inverted | bright-black / bright-white | none | + +Each pair must remain legible but visibly subordinate to `panel.file`; the existing theme contrast tests are extended to assert `panel.hidden != panel.file` in every built-in theme. + +### D3: Precedence is a single ordered match + +`style = drag_target ?? cursor ?? selected ?? (hidden → panel.hidden) ?? (dir → panel.directory | panel.file)`. Hidden directories lose their bright-white directory style in favor of `panel.hidden` (uppercase/marker glyphs unchanged), so `.git` reads as "hidden" rather than "important". The git marker column keeps its own roles. + +## Risks / Trade-offs + +- [`bright-black` on `blue` (nc-classic) is low contrast on some palettes] → That is the intended "dimmed" look; the value can be tuned per theme without renderer changes. +- [Adding a role grows every theme table by one line] → No user theme loader exists today, so no external file breaks. + +## Open Questions + +- None. diff --git a/openspec/changes/hidden-entry-styling/proposal.md b/openspec/changes/hidden-entry-styling/proposal.md new file mode 100644 index 0000000..eb24583 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/proposal.md @@ -0,0 +1,32 @@ +# Change: hidden-entry-styling + +## Why + +Listings show every entry, including Windows hidden and system files and directories (`.git`, `desktop.ini`, `$RECYCLE.BIN`, `System Volume Information`), styled exactly like ordinary entries. `RawDirEntry` keeps no attribute bits even though the enumeration metadata already carries them (`fs.rs` reads `file_attributes()` for readonly/reparse). Users cannot tell at a glance which entries are the ones Explorer hides. The user chose to keep showing them but in a distinct color — no toggle. + +## What Changes + +- `Entry` gains `hidden: bool`, set from the enumeration: on Windows when `FILE_ATTRIBUTE_HIDDEN` or `FILE_ATTRIBUTE_SYSTEM` is set; elsewhere when the name starts with `.`. `..` is never hidden. +- A new theme role **`panel.hidden`** is added to every built-in theme with a visibly dimmer color than `panel.file` on the same background; hidden files and hidden directories both render with it in Full and Brief modes. +- Precedence: drag-target highlight > cursor bar > selected > hidden > directory/file (the first two in the order the renderer already uses). Sorting, filtering, selection, the mini-status, Tree mode, Quick view, and git markers are unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `theme-system`: new requirement "`panel.hidden` role" (present in every built-in theme; validation; per-theme values). +- `panel-navigation`: "Entry row rendering" adds hidden styling and precedence. +- `additional-panel-modes`: "Brief display mode" states hidden styling applies in Brief too. + +## Impact + +- `crates/filecommand-core/src/listing.rs` — `RawDirEntry.hidden`, `Entry.hidden`, `StdFsReader::read_dir` reads the attribute bits (`cfg(windows)`) or the dot prefix. +- `crates/filecommand-core/src/theme.rs` — `Role::PanelHidden` (`"panel.hidden"`), `ALL_ROLES`, six built-in tables. +- `crates/filecommand-tui/src/views/panel.rs` — entry style resolution in Full and Brief. +- Tests: listing attribute mapping (fake reader), theme tables, a snapshot with hidden entries. +- Non-breaking; **note** that `ALL_ROLES` validation means any future user theme file must define `panel.hidden`. +- **Overlap:** `full-mode-column-widths` also carries a MODIFIED "Entry row rendering" delta; whichever lands second re-bases its text on the other's merged requirement. diff --git a/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md b/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md new file mode 100644 index 0000000..256493e --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Brief display mode + +The panel SHALL provide a Brief display mode that lists entry names only, arranged in `max(1, floor(interior_width / 12))` columns across the panel width — where `interior_width` is the panel's width inside its border — with the division remainder given to the last column and no Size/Date/Time columns. At the 80×24 nominal size with the default split this yields exactly three columns. Column alignment MUST use display width (`unicode-width`) so that CJK, emoji, and wide-glyph names stay aligned, and entries MUST retain the standard directory/file/hidden/selection/cursor styling and the `▶UP--DIR◀` marker for `..`, with the same style precedence as Full mode (`panel-navigation` "Entry row rendering"). + +#### Scenario: Brief mode renders three columns at the nominal size + +- **WHEN** the panel is in Brief display mode at terminal size 80×24 with the default 50/50 split (interior width 38) +- **THEN** entry names are laid out across exactly three columns of widths 12, 12, and 14, and no size, date, or time fields are shown for any entry + +#### Scenario: Wider panels earn more columns + +- **WHEN** the panel is in Brief mode with an interior width of 60 +- **THEN** entry names are laid out across five columns + +#### Scenario: Narrow panels drop to fewer columns + +- **WHEN** the panel is in Brief mode with an interior width of 23 +- **THEN** entry names are laid out in a single column spanning the interior width + +#### Scenario: Brief mode aligns wide-character names by display width + +- **WHEN** the panel is in Brief mode and a column contains a name with CJK or emoji characters +- **THEN** the following column starts at the same screen offset for every row, using `unicode-width` display width rather than byte or `char` count + +#### Scenario: Brief mode preserves entry styling and parent marker + +- **WHEN** the panel is in Brief mode and contains directories, files, a selected entry, and the `..` parent entry +- **THEN** directories render in the directory style, files in the file style, selected entries in the selected style, the cursor as a full-width inverse bar, and `..` renders as `▶UP--DIR◀` + +#### Scenario: Brief mode dims hidden entries + +- **WHEN** a panel in Brief mode lists a hidden file and a hidden directory +- **THEN** both names render with the `panel.hidden` role, with the same precedence against cursor and selection as in Full mode diff --git a/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md b/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md new file mode 100644 index 0000000..630c917 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Entry row rendering + +The system SHALL render entry rows so directories appear in the bright-white directory style and files in the file style, with `▶UP--DIR◀` shown for the `..` entry and `▶SUB-DIR◀` shown in the Size column for directories. Entries flagged hidden — on Windows those carrying the hidden or system attribute, elsewhere names beginning with `.` — SHALL render their name with the `panel.hidden` role instead of the file or directory style; the `..` entry is never hidden. Style precedence SHALL be: drag-target highlight, then cursor bar, then selected, then hidden, then directory/file. The entry under the cursor SHALL render as a full-width inverse bar using the `panel.cursor` role. Rendering SHALL use only ANSI-16 named color roles and CP437 box-drawing/geometric glyphs, with no emoji, Nerd Font, or file-type icons. + +#### Scenario: Directory entry styling + +- **WHEN** an entry is a directory +- **THEN** its name SHALL render in the `panel.directory` style and its Size column SHALL read `▶SUB-DIR◀` + +#### Scenario: Parent entry styling + +- **WHEN** the entry list contains the parent `..` entry +- **THEN** it SHALL render as `▶UP--DIR◀` in the `panel.directory` style + +#### Scenario: Hidden entries render dimmed + +- **WHEN** a directory listing contains `desktop.ini` (hidden attribute) and `.git` (hidden directory) +- **THEN** both names render with the `panel.hidden` role, `.git` still shows `▶SUB-DIR◀`, and neither entry's position in the sorted listing changes + +#### Scenario: Selected hidden entry shows selected style + +- **WHEN** a hidden file is selected with Ins +- **THEN** its row renders with the `panel.selected` role, not `panel.hidden` + +#### Scenario: Cursor bar wins + +- **WHEN** the panel cursor is on a hidden entry +- **THEN** that row SHALL render as the full-width `panel.cursor` inverse bar + +#### Scenario: Cursor row is an inverse bar + +- **WHEN** an entry is under the panel cursor +- **THEN** that row SHALL render as a full-width inverse bar using the `panel.cursor` role diff --git a/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md b/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md new file mode 100644 index 0000000..37536c6 --- /dev/null +++ b/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: `panel.hidden` role + +The theme model SHALL include a `panel.hidden` role used for entries flagged hidden or system. Every built-in theme SHALL define it with a mandatory ANSI-16 pair that is visibly dimmer than, and distinct from, that theme's `panel.file` role on the same background, with an optional truecolor override; a theme lacking the role SHALL fail validation like any other missing role. + +#### Scenario: Built-in themes define the role +- **WHEN** any built-in theme (`nc-classic`, `nc-mono`, `terminal-green`, `purple-lights`, `yellow-storm`, `inverted`) is loaded +- **THEN** `panel.hidden` resolves to a defined color pair that differs from the theme's `panel.file` pair + +#### Scenario: nc-classic hidden color +- **WHEN** `nc-classic` is active and a hidden file entry is drawn +- **THEN** its name renders bright-black on blue + +#### Scenario: Missing role is rejected +- **WHEN** a theme table omits `panel.hidden` +- **THEN** theme validation fails naming `panel.hidden` diff --git a/openspec/changes/hidden-entry-styling/tasks.md b/openspec/changes/hidden-entry-styling/tasks.md new file mode 100644 index 0000000..262e88a --- /dev/null +++ b/openspec/changes/hidden-entry-styling/tasks.md @@ -0,0 +1,26 @@ +# Tasks: hidden-entry-styling + +## 1. Listing (`crates/filecommand-core/src/listing.rs`) + +- [ ] 1.1 `RawDirEntry.hidden`, `Entry.hidden` (`Entry::parent_dir()` → `false`); `From` copies it (panel-navigation "Entry row rendering": "Hidden entries render dimmed") +- [ ] 1.2 `StdFsReader::read_dir`: `cfg(windows)` attribute test (`0x2 | 0x4`), else dot-prefix (design D1) +- [ ] 1.3 Tests: fake reader entries with the flag survive sorting and quick-filter narrowing; `..` is never hidden + +## 2. Theme (`crates/filecommand-core/src/theme.rs`) + +- [ ] 2.1 `Role::PanelHidden` → `"panel.hidden"`; add to `ALL_ROLES`; six built-in tables per design D2 (theme-system "`panel.hidden` role") +- [ ] 2.2 Tests: every built-in defines it; it differs from `panel.file` in every theme; `Theme::build` rejects a table missing it (scenario "Missing role is rejected") + +## 3. Renderer (`crates/filecommand-tui/src/views/panel.rs`) + +- [ ] 3.1 Full and Brief entry style resolution per design D3 (scenarios "Selected hidden entry shows selected style", "Cursor bar wins"; additional-panel-modes "Brief mode dims hidden entries") +- [ ] 3.2 Snapshot test: a listing with a hidden file, a hidden directory, a selected hidden file, and the cursor on a hidden entry (Full and Brief) + +## 4. Docs + +- [ ] 4.1 README "Themes": `panel.hidden` in the role list; "Panel display modes": hidden/system entries render dimmed + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate hidden-entry-styling --strict` clean +- [ ] 5.2 Manual: browse `C:\` (`$RECYCLE.BIN`, `System Volume Information` dimmed) and a repo (`.git` dimmed) in each built-in theme From ae80023861192778ecc2fc428160e0361c222a8a Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:19 -0600 Subject: [PATCH 08/14] Add OpenSpec proposal pulldown-menu-completion Wire the greyed pull-down items that already have backing features (View, Edit, Filter, New/Close tab), add the small missing ones (Alt+F8 history dialog, Ctrl+U swap panels, Menu file edit with reload), and remove Attributes, Compare directories, Configuration, Editor selection, and Save setup. Co-Authored-By: Claude Fable 5.1 --- .../pulldown-menu-completion/.openspec.yaml | 2 + .../pulldown-menu-completion/design.md | 53 +++++++++++++++++++ .../pulldown-menu-completion/proposal.md | 35 ++++++++++++ .../specs/command-line/spec.md | 17 ++++++ .../specs/panel-navigation/spec.md | 21 ++++++++ .../specs/pulldown-menus/spec.md | 47 ++++++++++++++++ .../specs/user-menu/spec.md | 13 +++++ .../changes/pulldown-menu-completion/tasks.md | 31 +++++++++++ 8 files changed, 219 insertions(+) create mode 100644 openspec/changes/pulldown-menu-completion/.openspec.yaml create mode 100644 openspec/changes/pulldown-menu-completion/design.md create mode 100644 openspec/changes/pulldown-menu-completion/proposal.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/command-line/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md create mode 100644 openspec/changes/pulldown-menu-completion/tasks.md diff --git a/openspec/changes/pulldown-menu-completion/.openspec.yaml b/openspec/changes/pulldown-menu-completion/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/pulldown-menu-completion/design.md b/openspec/changes/pulldown-menu-completion/design.md new file mode 100644 index 0000000..b8e786f --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/design.md @@ -0,0 +1,53 @@ +# Design: pulldown-menu-completion + +## Context + +`menu.rs:150-215` item tables; `update.rs:2406-2427` `MenuAction → Command`; `MenuId::target_side` resolves Left/Right menus to their own side. `OpenTab`/`CloseTab`/`QuickFilterStart` act on `state.active`. `state.history` is the persisted command list (newest last, 200 cap). `handle_request_editor` opens the cursor entry via `Effect::OpenEditor{path}` (built-in) or `Effect::RunExternalEditor(EditorInvocation, PanelSide)` (external); both are built from a path, so a menu-file edit is one new command that supplies the user-menu path instead of the cursor entry. `startup_warning` is a dismissable dialog usable mid-session. In-flight Info/git replies carry a `PanelSide` and a request id; listing chunks carry only the `PanelSide`. The `F(8)` keymap arm has no modifier guard, so Alt+F8 currently opens the delete confirmation. + +## Goals / Non-Goals + +**Goals:** + +- No greyed item left. +- Each new feature minimal and NC-faithful (Alt+F8 history, Ctrl+U swap). +- Left/Right menu items act on their own side. + +**Non-Goals:** + +- Attributes dialog, directory compare, a configuration dialog, editor selection UI, "Save setup" (settings already persist on change). +- Executing a history entry directly from the dialog (Enter places it; the user presses Enter again to run — one extra key, no accidental runs). + +## Decisions + +### D1: Side-carrying commands for the Left/Right items + +`Command::OpenTab(PanelSide)`, `CloseTab(PanelSide)`, `QuickFilterStart(PanelSide)`; the keymap passes `state.active`. For the quick filter, typing must reach the filtered panel, so `QuickFilterStart(side)` also makes `side` the active panel (a visible but expected focus change, as with Left → Info today via `ToggleInfoMode(side)`). + +Alternative rejected: keeping side-less commands and switching the active panel before dispatch — it would leave the keyboard focus moved after New tab/Close tab too, where nothing requires it. + +### D2: History dialog is a list overlay like the user menu + +`HistoryDialogState { cursor }` over `state.history` rendered newest-first; Up/Down/Home/End/PgUp/PgDn; Enter → `command_line = entry` (caret at end, `history_cursor = None`), close; Esc → close. Empty history renders a single `(no history)` row and Enter does nothing. Opened by Alt+F8 or Commands → History. Not mouse-enabled (joins the unsupported-overlay list in mouse-input). + +### D3: Swap panels exchanges `PanelState`s and re-issues async queries + +`std::mem::swap(&mut state.left, &mut state.right)`; `active` and `split_percent` unchanged (the left slot stays the left slot). Listing chunks are addressed only by `PanelSide` with no request id, so the swap is **refused while either panel's listing is streaming** (no-op; the mini-status already shows `Reading…`). Info/git replies do carry a request id, so after a swap the reducer re-issues `QueryInfo` (if in Info mode) and `QueryGitInfo` for both sides with fresh ids and any reply from before the swap is discarded as stale. Tree and Quick view modes keep working unchanged because both sides moved together. Drag state cannot exist while a menu is open; Ctrl+U during a drag is ignored. + +Alternative rejected: re-targeting in-flight replies by remembering a swap generation — more state for a case the streaming guard removes. + +### D4: Menu file edit reuses the F4 path and reloads on return + +`Command::EditUserMenuFile` → `Effect::OpenEditor{path: state.user_menu_path}` (built-in) or `RunExternalEditor` when `editor =` is set — the same branch `handle_request_editor` takes. The TUI, on editor close/return for that path, calls `config::load_user_menu` and sends `Command::UserMenuReloaded(result)`: entries replace `state.user_menu_entries`; a malformed file raises the existing warning dialog and keeps the previous entries, never overwriting the file (user-menu "Create and recover"). + +### D5: Remove, don't hide + +The five removed items disappear from the tables; Options becomes `Themes` alone (no separator). `MenuAction::Unimplemented` is retained for future proposals and the disabled-rendering unit tests build a local fixture menu instead of relying on shipped items. + +## Risks / Trade-offs + +- [Changing `OpenTab`/`CloseTab`/`QuickFilterStart` signatures touches existing tests] → Mechanical: every call site passes `state.active`. +- [A swap right after a job completes may race the automatic re-read] → Covered by D3's streaming guard; a swap while a job dialog is up cannot happen (the modal blocks menus and keys). + +## Open Questions + +- None. diff --git a/openspec/changes/pulldown-menu-completion/proposal.md b/openspec/changes/pulldown-menu-completion/proposal.md new file mode 100644 index 0000000..13508ee --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/proposal.md @@ -0,0 +1,35 @@ +# Change: pulldown-menu-completion + +## Why + +Thirteen pull-down items render greyed out (`MenuAction::Unimplemented` in `menu.rs`): Left/Right **Filter, New tab, Close tab**; Files **View, Edit, Attributes**; Commands **History, Swap panels, Compare directories, Menu file edit**; Options **Configuration, Editor selection, Save setup**. Eight of them front features that already exist (F3, F4, Ctrl+P, Ctrl+T, Ctrl+W) or are a few dozen lines away (a history list over `state.history`, a swap of two `PanelState`s, opening `usermenu.toml` in the editor). Greyed items in a shipping menu read as broken, and the pull-downs are the main discovery surface for keyboard users. The user chose to wire the cheap items and remove the rest. + +## What Changes + +- **Wired to existing commands:** Files → View (F3), Edit (F4); Left/Right → Filter (quick filter on that panel), New tab (Ctrl+T), Close tab (Ctrl+W), each acting on the menu's own panel side. +- **New, small:** Commands → **History** (new `Alt+F8` command-history dialog: pick an entry, Enter places it on the command line), **Swap panels** (new `Ctrl+U`: exchanges the two panels' entire state), **Menu file edit** (opens `usermenu.toml` with the F4 editor path and reloads the user menu on return). +- **Removed from the menus:** Files → Attributes; Commands → Compare directories; Options → Configuration, Editor selection, Save setup (Options now lists Themes only). They can return via their own proposals. +- The disabled-item rendering rule stays in the spec and renderer for future use; no shipped item is disabled after this change, so the "Not-yet-available feature renders disabled" scenario under "Menu contents" is dropped. +- Alt+F8 today falls through to the unguarded `F(8)` arm and opens the delete confirmation; the new Alt+F8 arm takes precedence, exactly as `F(7) if alt` does for find-file. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `pulldown-menus`: "Menu contents" rewritten to the final item lists; new requirement "Every listed item is wired" with per-item routing scenarios. +- `command-line`: new requirement "Command history dialog (Alt+F8)". +- `panel-navigation`: new requirement "Swap panels (Ctrl+U)". +- `user-menu`: new requirement "Edit the user menu file from the Commands menu". + +## Impact + +- `crates/filecommand-core/src/menu.rs` — item tables and `MenuAction` variants (`View`, `Edit`, `QuickFilter`, `NewTab`, `CloseTab`, `History`, `SwapPanels`, `EditMenuFile`); removed items. +- `crates/filecommand-core/src/update.rs` — `menu_action_command` mapping; `Command::OpenTab/CloseTab/QuickFilterStart` gain a `PanelSide` (the keymap passes the active side); `Command::SwapPanels`, `Command::HistoryDialogOpen/Move/Confirm/Cancel`, `Command::EditUserMenuFile`, `Command::UserMenuReloaded(..)`; `State.history_dialog: Option`, `State.user_menu_path: PathBuf`. +- `crates/filecommand-core/src/dialogs.rs` — `HistoryDialogState` (cursor over `state.history`, newest first). +- `crates/filecommand-tui` — `input/mod.rs` Alt+F8, Ctrl+U, history-dialog keymap; `views/history_dialog.rs` (primary style, like the user menu); `app.rs` reload of the user menu after the editor returns for the menu file; mouse: the history dialog is an unsupported overlay like the user menu. +- Tests across `menu.rs`, `update/tests.rs`, `input/tests.rs`, snapshots. +- Depends softly on `config-profile-directory` for the user-menu path (works with whichever path the app resolves). diff --git a/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md b/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md new file mode 100644 index 0000000..b844c91 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Command history dialog (Alt+F8) + +The system SHALL open a modal command-history dialog on Alt+F8 or Commands → History, listing the persisted command history newest first in the primary dialog style. Up/Down/Home/End/PgUp/PgDn SHALL move the highlight; Enter SHALL replace the command-line buffer with the highlighted entry (caret at the end, no history recall in progress) and close the dialog without running anything; Esc SHALL close it unchanged. An empty history SHALL show a single `(no history)` row on which Enter does nothing. The dialog SHALL NOT respond to the mouse (it joins the unsupported-overlay set). + +#### Scenario: Pick an entry +- **WHEN** the history holds `cd C:\work` then `cd D:\` and the user presses Alt+F8, Down, Enter +- **THEN** the dialog closes and the buffer reads `cd C:\work` with the caret at the end; nothing has run + +#### Scenario: Esc leaves the buffer alone +- **WHEN** the buffer holds `cd x` and the user opens the dialog and presses Esc +- **THEN** the dialog closes and the buffer still reads `cd x` + +#### Scenario: Empty history +- **WHEN** the history is empty and the user presses Alt+F8, then Enter +- **THEN** the dialog shows `(no history)` and Enter leaves the buffer unchanged with the dialog open until Esc diff --git a/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md b/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md new file mode 100644 index 0000000..7bb0425 --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Swap panels (Ctrl+U) + +The system SHALL exchange the left and right panels' complete state — directory, tabs, listing, cursor, scroll offset, selection, sort mode, filter, display mode, and git info — on Ctrl+U or Commands → Swap panels, keeping the active side and the split percentage unchanged, without re-reading either directory. A swap requested while either panel's listing is still streaming SHALL be ignored. After a swap the system SHALL re-issue each panel's pending Info-mode and git-info queries so no reply from before the swap is applied to the wrong panel. + +#### Scenario: Swap keeps focus on the same side +- **WHEN** the left panel is active showing `C:\a` and the right shows `D:\b`, and the user presses Ctrl+U +- **THEN** the left panel shows `D:\b`, the right shows `C:\a`, the left panel is still active, and the command-line prompt reads `D:\b>` + +#### Scenario: Swap carries tabs and selection +- **WHEN** the right panel has three tabs and two selected entries and the user presses Ctrl+U +- **THEN** the left panel now has those three tabs and the same two selected entries + +#### Scenario: Swap refused while reading +- **WHEN** the right panel's listing is still streaming and the user presses Ctrl+U +- **THEN** nothing changes + +#### Scenario: Git info follows the swap +- **WHEN** the left panel is inside a git repository and the user presses Ctrl+U +- **THEN** the right panel's border shows that repository's branch suffix after its re-issued query resolves, and the left panel shows none diff --git a/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md b/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md new file mode 100644 index 0000000..707d9ab --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Menu contents + +The system SHALL populate the five menus with their defined items: Left and Right SHALL each offer Brief, Full, Tree, Quick view, Info, a separator, Name, Extension, Modif. time, Size, Unsorted, a separator, Filter, Re-read, Drive select, a separator, New tab, Close tab (mirroring each other); Files SHALL offer View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, Quit; Commands SHALL offer Find file, History, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options SHALL offer Themes. Every listed item SHALL be enabled; the disabled-item rendering rule ("Pull-down visuals with separators and disabled items") remains available for future items but no shipped item uses it. + +#### Scenario: Files menu lists its items + +- **WHEN** the user opens the `Files` menu +- **THEN** the pull-down lists View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, and Quit, all enabled + +#### Scenario: Left and Right menus mirror each other + +- **WHEN** the user opens the `Left` menu and then the `Right` menu +- **THEN** both list the same display-mode, sort-mode, Filter, Re-read, Drive select, New tab, and Close tab items, all enabled + +#### Scenario: Commands and Options menus + +- **WHEN** the user opens `Commands` and then `Options` +- **THEN** Commands lists Find file, History, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options lists Themes; none is disabled + +#### Scenario: Removed items are absent + +- **WHEN** the user opens every menu +- **THEN** no menu lists Attributes, Compare directories, Configuration, Editor selection, or Save setup + +## ADDED Requirements + +### Requirement: Every listed item is wired + +Activating a pull-down item SHALL perform the same action as its keyboard equivalent, applied to the menu's target panel (Left/Right menus act on their own panel; Files, Commands, Options act on the active panel): Files → View and Edit SHALL follow the F3 and F4 paths; Left/Right → Filter SHALL start the quick filter on that panel (making it the active panel so typed characters narrow it); Left/Right → New tab and Close tab SHALL open or close a tab on that panel; Commands → History SHALL open the command-history dialog; Commands → Swap panels SHALL swap the panels; Commands → Menu file edit SHALL open the user-menu file in the editor. + +#### Scenario: Files → View opens the viewer +- **WHEN** the cursor is on `notes.txt` and the user activates Files → View +- **THEN** the menu closes and the F3 viewer opens on `notes.txt` + +#### Scenario: Right → Filter on the inactive panel +- **WHEN** the left panel is active and the user activates Right → Filter +- **THEN** the right panel becomes active with its quick filter open, and subsequent typed characters narrow the right panel + +#### Scenario: Left → New tab on the inactive panel +- **WHEN** the right panel is active and the user activates Left → New tab +- **THEN** the left panel gains a new tab exactly as Ctrl+T would have created with the left panel active + +#### Scenario: Commands → Menu file edit +- **WHEN** the user activates Commands → Menu file edit +- **THEN** the user-menu file opens in the F4 editor path (external editor when configured, else the built-in editor) diff --git a/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md b/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md new file mode 100644 index 0000000..9a7ff2c --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Edit the user menu file from the Commands menu + +The system SHALL open `usermenu.toml` (at the path the application resolved at startup) when the user activates Commands → Menu file edit, using the F4 editor path — the configured external editor when `editor =` is set, otherwise the built-in editor — and SHALL reload the user menu when the editor closes or returns. A well-formed file SHALL replace the F2 entries immediately; a malformed file SHALL raise the dismissable warning dialog and keep the previous entries, and SHALL NOT overwrite the file. + +#### Scenario: Added entry appears in F2 +- **WHEN** the user activates Commands → Menu file edit, appends a valid `[[entry]]`, saves, and leaves the editor +- **THEN** pressing F2 lists the new entry + +#### Scenario: Malformed edit warns without overwriting +- **WHEN** the user leaves the editor with invalid TOML in the file +- **THEN** a warning dialog names the parse error, F2 still shows the previous entries, and the file on disk is the user's edited text diff --git a/openspec/changes/pulldown-menu-completion/tasks.md b/openspec/changes/pulldown-menu-completion/tasks.md new file mode 100644 index 0000000..a6e8b2f --- /dev/null +++ b/openspec/changes/pulldown-menu-completion/tasks.md @@ -0,0 +1,31 @@ +# Tasks: pulldown-menu-completion + +## 1. Menu tables (`crates/filecommand-core/src/menu.rs`) + +- [ ] 1.1 Add `MenuAction::{View, Edit, QuickFilter, NewTab, CloseTab, History, SwapPanels, EditMenuFile}`; wire Left/Right Filter/New tab/Close tab, Files View/Edit, Commands History/Swap panels/Menu file edit (pulldown-menus "Every listed item is wired") +- [ ] 1.2 Remove Attributes, Compare directories, Configuration, Editor selection, Save setup; Options = `Themes` (pulldown-menus "Menu contents"; design D5) +- [ ] 1.3 Tests: item lists match the spec; no shipped item is disabled; disabled-rendering tests use a local fixture (design D5) + +## 2. Reducer (`crates/filecommand-core/src/update.rs`, `dialogs.rs`) + +- [ ] 2.1 `menu_action_command` maps the new actions; `OpenTab/CloseTab/QuickFilterStart` take `PanelSide`; `QuickFilterStart(side)` activates `side` (design D1; scenarios "Right → Filter on the inactive panel", "Left → New tab on the inactive panel") +- [ ] 2.2 `HistoryDialogState` + `Command::HistoryDialogOpen/Move/Confirm/Cancel`; Enter places the entry with the caret at the end and clears `history_cursor` (command-line "Command history dialog (Alt+F8)"; design D2) +- [ ] 2.3 `Command::SwapPanels`: streaming guard, swap, `active`/split unchanged, re-issue Info/git queries with fresh ids (panel-navigation "Swap panels (Ctrl+U)"; design D3) +- [ ] 2.4 `State.user_menu_path`; `Command::EditUserMenuFile` → editor effect for that path; `Command::UserMenuReloaded(UserMenuLoadResult)` applies entries or the malformed warning (user-menu "Edit the user menu file from the Commands menu"; design D4) +- [ ] 2.5 Tests: each menu item issues its command; history dialog navigation/confirm/cancel/empty; swap preserves the active side and split, refuses during streaming, re-queries git/Info; menu-file edit effect path and reload (well-formed and malformed) + +## 3. TUI (`crates/filecommand-tui`) + +- [ ] 3.1 `input/mod.rs`: `F(8) if alt` → `HistoryDialogOpen` placed before the plain `F(8)` arm (which has no modifier guard today); Ctrl+U → `SwapPanels`; history-dialog keymap; `OpenTab/CloseTab/QuickFilterStart` pass `state.active`; `input/tests.rs` coverage +- [ ] 3.2 `views/history_dialog.rs`: primary-style list (reuse the user-menu renderer's geometry); snapshot tests (populated, empty) +- [ ] 3.3 `app.rs`: set `user_menu_path`; after the built-in editor closes or the external editor returns on that path, reload and send `UserMenuReloaded` +- [ ] 3.4 Mouse: the history dialog is added to the unsupported-overlay set (no clicks); verify existing mouse tests + +## 4. Docs + +- [ ] 4.1 README "Pull-down menus (F9)" rewritten; keyboard reference gains Alt+F8 and Ctrl+U; F1 Help "Menus" and "Keyboard reference" pages + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `openspec validate pulldown-menu-completion --strict` clean +- [ ] 5.2 Manual: every F9 item does something; Alt+F8 → pick → Enter → Enter runs; Ctrl+U in a git repo swaps and the branch suffix follows; Commands → Menu file edit, add an entry, save → F2 shows it From c040292e3024cd18cde11f8a0419cffcf13234d2 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:09:19 -0600 Subject: [PATCH 09/14] Add OpenSpec proposal open-with-associated-app Add an Open entry to the file-action menu (first and highlighted for documents, after Run for executables, Explorer for directories) that launches the target through the platform file association without suspending the TUI and reports failures on the panel status line. Co-Authored-By: Claude Fable 5.1 --- .../open-with-associated-app/.openspec.yaml | 2 + .../open-with-associated-app/design.md | 50 +++++++ .../open-with-associated-app/proposal.md | 30 +++++ .../specs/file-action-menu/spec.md | 122 ++++++++++++++++++ .../changes/open-with-associated-app/tasks.md | 23 ++++ 5 files changed, 227 insertions(+) create mode 100644 openspec/changes/open-with-associated-app/.openspec.yaml create mode 100644 openspec/changes/open-with-associated-app/design.md create mode 100644 openspec/changes/open-with-associated-app/proposal.md create mode 100644 openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md create mode 100644 openspec/changes/open-with-associated-app/tasks.md diff --git a/openspec/changes/open-with-associated-app/.openspec.yaml b/openspec/changes/open-with-associated-app/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/open-with-associated-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/open-with-associated-app/design.md b/openspec/changes/open-with-associated-app/design.md new file mode 100644 index 0000000..b0c61cf --- /dev/null +++ b/openspec/changes/open-with-associated-app/design.md @@ -0,0 +1,50 @@ +# Design: open-with-associated-app + +## Context + +`FileActionMenuState::open(target, is_dir, executable, selection_scoped)` builds the entry list; `update.rs:2691-2701` routes Run to `Effect::RunShellCommand` (suspends the TUI, waits for a key) and View/Edit to the F3/F4 handlers. `Effect`s are executed in `app.rs`; failures come back as `Command`s that set `panel.last_error` (the pattern `EditorOpenFailed` uses). `clipboard-export` already strips `\\?\` before handing paths to Win32 (its "Long-path prefix is stripped" scenario under "Windows file-object payload"). `windows-sys` is only a transitive dependency today. + +## Goals / Non-Goals + +**Goals:** + +- One keystroke pair (Enter, Enter) to open a document in its app. +- No terminal takeover. +- Clear failure text. +- Explorer for directories. + +**Non-Goals:** + +- Choosing the application ("Open with…"). +- Waiting for the app and re-reading the panel. +- Associations configurable in FileCommand. +- Replacing Run (console programs still need the suspended path so their output is visible). + +## Decisions + +### D1: `ShellExecuteW` on Windows, detached spawn elsewhere + +`ShellExecuteW(NULL, L"open", path, NULL, cwd, SW_SHOWNORMAL)` via `windows-sys`; a return value ≤ 32 is an error code: `SE_ERR_NOASSOC` (31) → `No application is associated with `; `ERROR_FILE_NOT_FOUND` (2) → ` not found`; anything else → `Could not open (error )`. Non-Windows: `xdg-open` (Linux/BSD) or `open` (macOS) with stdio set to null and no wait; a spawn error maps to the same failure command. The TUI is never suspended, so a GUI app simply appears beside the terminal. + +Alternative rejected: `cmd /C start "" ""` through the existing shell path — it suspends the TUI, waits for a key, and `start`'s quoting rules are a known trap. + +### D2: Entry order and default highlight + +Non-executable file: `Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete` (Open highlighted). Executable: `Run, Open, View, …` (Run highlighted). Directory (right-click): `Open, Send to clipboard, Copy, Rename, Move, Delete`. Hotkey `O` is unique; `R` keeps resolving to Run when listed, else Rename. + +### D3: Single-target, no selection scope, no re-read + +Like Run, Open acts on the menu's target only, even when the target is selected. Because the launch is fire-and-forget, no panel re-read is scheduled; Ctrl+R remains the user's refresh. + +### D4: Console-application associations + +If a file type is associated with a console program (e.g. `.py` → `python.exe`), ShellExecute attaches it to our console and its output interleaves with the TUI. Documented in the README as the reason Run exists; not mitigated in this change. + +## Risks / Trade-offs + +- [New compile-time dependency on `windows-sys` (cfg(windows) only)] → Small, widely used, no runtime cost. +- [Default highlight moves from View to Open] → Muscle memory of "Enter, Enter = View" changes once; user-approved. + +## Open Questions + +- None. diff --git a/openspec/changes/open-with-associated-app/proposal.md b/openspec/changes/open-with-associated-app/proposal.md new file mode 100644 index 0000000..973ee0b --- /dev/null +++ b/openspec/changes/open-with-associated-app/proposal.md @@ -0,0 +1,30 @@ +# Change: open-with-associated-app + +## Why + +Enter on a document opens the file-action menu, whose entries are View, Edit, Send to clipboard, Copy, Rename, Move, Delete — and Run for executables only. There is no way to open a `.pdf`, `.png`, `.docx`, or `.xlsx` in the application Windows associates with it; View shows bytes and Edit opens a text editor. For a Windows-first file manager this is the most-used missing action. Norton Commander covered the same need through its extension file; the modern equivalent is `ShellExecute` with the `open` verb. + +## What Changes + +- A new **Open** entry in the file-action menu: first and highlighted for non-executable files (so Enter, Enter opens a document); for executables Run stays first and Open is second; for directory targets (right-click menu) Open is first and opens the directory in the system file browser. Hotkey `O`. +- Open launches the target through the platform's file association **without suspending the TUI** and without waiting: `ShellExecuteW(open)` on Windows, `xdg-open`/`open` detached elsewhere. The panel stays interactive. +- Failure (no association, launch error) is reported on the active panel's status line (`last_error`), e.g. `No application is associated with report.xyz`. +- Open is always single-target (like Run) and never touches the selection. Run, View, Edit, and every other entry are unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `file-action-menu`: "Menu contents, ordering, and navigation" (Open first, hotkey `O`), "Menu actions route to existing flows" (Open routing), "Directory targets and selection-scoped invocation" (Open first for directories), plus a new requirement "Open with the associated application" (launch semantics, non-blocking, failure reporting). + +## Impact + +- `crates/filecommand-core/src/dialogs.rs` — `FileActionMenuEntry::Open`, ordering, label/hotkey; `update.rs` — `Command::FileActionMenuConfirm` routing to `Effect::OpenWithAssociation{path, cwd}`; `Command::OpenWithAssociationFailed{message}` → `last_error`. +- `crates/filecommand-tui/src/app.rs` — effect handler; new `cfg(windows)` dependency `windows-sys` (features `Win32_UI_Shell`, `Win32_Foundation`, `Win32_UI_WindowsAndMessaging`) in `crates/filecommand-tui/Cargo.toml`; non-Windows `std::process::Command` detached spawn. +- Paths handed to the shell API are stripped of the `\\?\` verbatim prefix the same way `clipboard-export` does (its "Long-path prefix is stripped" scenario). +- Tests: menu contents/hotkeys, routing effect, failure message; snapshot of the menu with Open first. +- Non-breaking for keys; **changes the default highlighted entry** from View to Open for non-executables (user decision). diff --git a/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md b/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md new file mode 100644 index 0000000..fdeb58d --- /dev/null +++ b/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md @@ -0,0 +1,122 @@ +## MODIFIED Requirements + +### Requirement: Menu contents, ordering, and navigation + +The file-action menu SHALL list the entries Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order for a non-executable file, and SHALL list Run first (before Open) when the target is executable (PATHEXT match or `.lnk`). The menu SHALL render as a primary-style modal dialog (§4.4) with the first entry highlighted on open. Up/Down SHALL move the highlight, Enter SHALL activate the highlighted entry and close the menu, Esc SHALL close the menu with no action taken, and pressing an entry's first letter SHALL activate that entry directly (`O` resolves to Open; `R` resolves to Run when Run is listed, otherwise to Rename). Rendering SHALL use only ANSI-16 named color roles and CP437 glyphs. + +#### Scenario: Non-executable menu contents + +- **WHEN** the menu opens for `notes.txt` +- **THEN** it lists Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order with Open highlighted + +#### Scenario: Executable gets Run first + +- **WHEN** the menu opens for `setup.exe` +- **THEN** it lists Run, Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete with Run highlighted +- **AND** pressing Enter immediately activates Run + +#### Scenario: Esc closes with no action + +- **WHEN** the menu is open and the user presses Esc +- **THEN** the menu closes, no action runs, and the panel cursor and selection are unchanged + +#### Scenario: First-letter hotkey activates directly + +- **WHEN** the menu is open for `notes.txt` and the user presses `D` +- **THEN** the Delete action is activated exactly as if it had been highlighted and Enter pressed + +#### Scenario: O activates Open + +- **WHEN** the menu is open for `report.pdf` and the user presses `O` +- **THEN** the Open action is activated exactly as if it had been highlighted and Enter pressed + +#### Scenario: S activates Send to clipboard + +- **WHEN** the menu is open for `notes.txt` and the user presses `S` +- **THEN** the Send to clipboard action is activated exactly as if it had been highlighted and Enter pressed + +### Requirement: Menu actions route to existing flows + +Each file-action menu entry SHALL route into the existing capability flow for that action, applied to the menu's target entry: Open SHALL launch the target through the platform file association per "Open with the associated application"; View SHALL open the F3 viewer; Edit SHALL follow the F4 edit path (external editor when configured, otherwise the built-in editor rules); Copy SHALL open the F5 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Move SHALL open the F6 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Delete SHALL open the F8 delete-confirmation flow for the single target entry; Send to clipboard SHALL run the `clipboard-export` Files action scoped to the single target entry; Run (executables only) SHALL use the existing suspended-TUI spawn path. Downstream behavior — overwrite conflicts, progress, error recovery, skipped-files summary, and panel re-read on completion — SHALL follow the existing `file-operations` and `operation-dialogs` requirements unchanged. + +#### Scenario: Open launches the associated application + +- **WHEN** the user activates Open for `report.pdf` +- **THEN** the menu closes, `report.pdf` is handed to the platform file association, and the panels remain interactive without any TUI suspension + +#### Scenario: View opens the viewer + +- **WHEN** the user activates View for `notes.txt` +- **THEN** the menu closes and the F3 viewer opens on `notes.txt` + +#### Scenario: Copy opens the destination dialog for the single entry + +- **WHEN** the left panel is active, the right panel shows `D:\backup`, and the user activates Copy for `report.txt` +- **THEN** the menu closes and the destination-input dialog opens pre-filled with `D:\backup` +- **AND** accepting it starts a copy job whose scope is exactly `report.txt` + +#### Scenario: Delete requires the existing confirmation + +- **WHEN** the user activates Delete for `notes.txt` +- **THEN** the menu closes and the delete-confirmation dialog names `notes.txt` and states that deletion is permanent +- **AND** declining the confirmation deletes nothing + +#### Scenario: Send to clipboard copies the single entry + +- **WHEN** the user activates Send to clipboard for `notes.txt` +- **THEN** the menu closes and the clipboard holds a file object for exactly `notes.txt` + +#### Scenario: Run spawns via the suspended-TUI path + +- **WHEN** the user activates Run for `build.bat` +- **THEN** the menu closes and `build.bat` runs via the suspended-TUI spawn path in the panel's current directory, exactly as a command-line invocation would + +### Requirement: Directory targets and selection-scoped invocation + +When the file-action menu is opened by a mouse right-click, the system SHALL allow a directory as the target, omitting View, Edit, and Run from the menu; and when the target entry is a member of the panel's selection set, Copy, Move, Delete, and Send to clipboard SHALL act on the whole selection set, with the resulting dialog naming the count. Open SHALL always act on the single target, never the selection. Enter-key invocation SHALL remain single-target and file-only as previously specified. The directory menu SHALL list its entries as Open, Send to clipboard, Copy, Rename, Move, Delete in that order, with Open highlighted; Open on a directory SHALL show that directory in the platform file browser. + +#### Scenario: Directory menu contents + +- **WHEN** the menu opens by right-click on `src` +- **THEN** it lists Open, Send to clipboard, Copy, Rename, Move, Delete in that order with Open highlighted + +#### Scenario: Open on a directory shows it in the file browser + +- **WHEN** the user activates Open for the directory `src` +- **THEN** `src` is handed to the platform file association (Explorer on Windows) and the panel does not navigate + +#### Scenario: Selection-scoped delete + +- **WHEN** three entries are selected and the user right-clicks one of them and activates Delete +- **THEN** the delete-confirmation dialog names three entries + +#### Scenario: Open ignores the selection + +- **WHEN** three entries are selected and the user right-clicks one of them and activates Open +- **THEN** only that entry is handed to the platform file association + +#### Scenario: Enter stays single-target + +- **WHEN** three entries are selected and the user presses Enter on one of them and activates Copy +- **THEN** the destination-input dialog is scoped to that single entry + +## ADDED Requirements + +### Requirement: Open with the associated application + +The system SHALL launch the Open target through the platform's file association — `ShellExecute` with the `open` verb on Windows, `xdg-open`/`open` elsewhere — with the active panel's directory as the working directory, without suspending the TUI, without waiting for the launched program, and without re-reading the panel. Paths SHALL be passed without the `\\?\` verbatim prefix. When the launch fails, the system SHALL report the reason on the active panel's status line: `No application is associated with ` when no association exists, ` not found` when the target is missing, otherwise `Could not open (error )`. + +#### Scenario: Document opens while the panels stay live + +- **WHEN** the user activates Open for `budget.xlsx` +- **THEN** the associated application starts, the terminal is not suspended, and the next key press is handled by the panels + +#### Scenario: No association is reported inline + +- **WHEN** the user activates Open for `data.zzz` and no application is associated with `.zzz` +- **THEN** the active panel's status line reads `No application is associated with data.zzz` and no dialog opens + +#### Scenario: Long path is passed without the verbatim prefix + +- **WHEN** the target's absolute path exceeds 260 characters +- **THEN** the path handed to the platform API carries no `\\?\` prefix diff --git a/openspec/changes/open-with-associated-app/tasks.md b/openspec/changes/open-with-associated-app/tasks.md new file mode 100644 index 0000000..63894b3 --- /dev/null +++ b/openspec/changes/open-with-associated-app/tasks.md @@ -0,0 +1,23 @@ +# Tasks: open-with-associated-app + +## 1. Core (`crates/filecommand-core`) + +- [ ] 1.1 `dialogs.rs`: `FileActionMenuEntry::Open` with label `Open`/hotkey `O`; ordering per design D2 for file, executable, and directory targets (file-action-menu "Menu contents, ordering, and navigation"; "Directory targets and selection-scoped invocation") +- [ ] 1.2 `update.rs`: `Effect::OpenWithAssociation { path: PathBuf, cwd: PathBuf }`; Open routes to it single-target (design D3); `Command::OpenWithAssociationFailed { message }` sets the active panel's `last_error` (file-action-menu "Open with the associated application") +- [ ] 1.3 Tests: menu order and highlight for the three target kinds; `O` hotkey; Open on a selected target emits one effect for that target only; the failure command lands on `last_error` + +## 2. TUI (`crates/filecommand-tui`) + +- [ ] 2.1 `Cargo.toml`: `[target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }` +- [ ] 2.2 `app.rs`: `open_with_association(path, cwd) -> Result<(), String>`; Windows `ShellExecuteW` with the `\\?\` prefix stripped (reuse the clipboard-export helper) and error-code mapping per design D1; non-Windows detached `xdg-open`/`open` +- [ ] 2.3 Dispatch the effect without suspending the terminal; map `Err` to `Command::OpenWithAssociationFailed` +- [ ] 2.4 Snapshot tests: file menu with Open first and highlighted; executable menu with Run then Open; directory menu with Open first + +## 3. Docs + +- [ ] 3.1 README: file-action menu entry lists (Enter and right-click), "Running programs" section explains Open vs Run (design D4); F1 Help "File operations" page + +## 4. Verify + +- [ ] 4.1 `cargo test --workspace` green; `openspec validate open-with-associated-app --strict` clean +- [ ] 4.2 Manual: Enter, Enter on a `.pdf` opens the reader while FileCommand stays live; right-click a directory → Open shows it in Explorer; a file with an unknown extension reports the no-association message on the status line From 22bada244e985984b0e334e7132ac7c7fea132af Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:25:09 -0600 Subject: [PATCH 10/14] Fix wording errors found by the proposal review help-flags: the flag is accepted anywhere, not only first. delete-key-triggers-delete: the guard is an exact no-modifier check, not is_plain. delete-builtins-reject-dot-targets: the function is dispatch_delete_builtin. reread-preserves-cursor: exclude the Tree-mode preview caller, restore TabData, handle quick-filter and Brief-mode offsets. ui-animations: --noanimations precedence is applied in run, stamp About and the startup warning, and correct the config and usage wording. Co-Authored-By: Claude Sonnet 5.5 --- .../changes/delete-builtins-reject-dot-targets/design.md | 4 ++-- .../changes/delete-builtins-reject-dot-targets/proposal.md | 2 +- .../changes/delete-builtins-reject-dot-targets/tasks.md | 2 +- openspec/changes/delete-key-triggers-delete/design.md | 4 ++-- openspec/changes/delete-key-triggers-delete/tasks.md | 2 +- openspec/changes/help-flags/design.md | 2 +- openspec/changes/help-flags/proposal.md | 2 +- openspec/changes/reread-preserves-cursor/design.md | 6 +++--- openspec/changes/reread-preserves-cursor/tasks.md | 6 +++--- openspec/changes/ui-animations/design.md | 6 +++--- openspec/changes/ui-animations/proposal.md | 4 ++-- openspec/changes/ui-animations/specs/ui-animations/spec.md | 6 +++--- openspec/changes/ui-animations/tasks.md | 4 ++-- 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/openspec/changes/delete-builtins-reject-dot-targets/design.md b/openspec/changes/delete-builtins-reject-dot-targets/design.md index 34ca561..ff86a26 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/design.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/design.md @@ -1,6 +1,6 @@ ## Context -`run_delete_builtin` (`crates/filecommand-core/src/update.rs`) determines the target's file-vs-directory type in two ways: a bare name that matches a currently-listed entry answers from the loaded `EntryKind`, and anything else gets a filesystem check. The panel's listing always contains a `..` row (`EntryKind::ParentDir`, and `is_dir_like()` is true for it), so: +`dispatch_delete_builtin` (`crates/filecommand-core/src/update.rs`) determines the target's file-vs-directory type in two ways: a bare name that matches a currently-listed entry answers from the loaded `EntryKind`, and anything else gets a filesystem check. The panel's listing always contains a `..` row (`EntryKind::ParentDir`, and `is_dir_like()` is true for it), so: - `del ..` is rejected today — but only incidentally, by the type check (`ParentDir` reads as a directory). The rejection message (".. is a directory") misdescribes the real problem. - `rmdir ..` sails through: listed ⇒ typed-as-directory ⇒ delete-confirmation dialog opens naming the parent directory. Accepting it (plus the second confirmation) dispatches a real delete job against the parent. @@ -23,7 +23,7 @@ F8 and the file-action menu's Delete entry never reach this state: their scope c - **Reject via `Path::components`, not string equality.** `Path::new("..\\").components()` yields just `ParentDir` (trailing separators are normalized), so one components check covers `..`, `..\`, `.`, and `.\` — string comparison would miss the separator variants. A multi-component path like `..\sibling` is unaffected (its first component is not the whole target). - **Reject before the listed-entry lookup and the filesystem check.** Both degenerate names are pure syntax; spending an `is_dir()` stat or consulting the listing for them is wasted work and muddies which rule fired. -- **Message shape: `"{verb}: invalid target {target}"`** — the same wording `run_delete_builtin` already uses for the unresolvable-target case, keeping the verb's errors self-consistent. +- **Message shape: `"{verb}: invalid target {target}"`** — the same wording `dispatch_delete_builtin` already uses for the unresolvable-target case, keeping the verb's errors self-consistent. ## Risks / Trade-offs diff --git a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md b/openspec/changes/delete-builtins-reject-dot-targets/proposal.md index 05180e7..9ef5711 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/proposal.md @@ -22,5 +22,5 @@ Both go through the confirmation dialog, so nothing deletes instantly — but a ## Impact -- `crates/filecommand-core/src/update.rs`: `run_delete_builtin` gains one early guard rejecting `.`/`..` targets before resolution; everything after is untouched. +- `crates/filecommand-core/src/update.rs`: `dispatch_delete_builtin` gains one early guard rejecting `.`/`..` targets before resolution; everything after is untouched. - New unit tests cover both verbs against `.` and `..` (no dialog, error set, no deletion). diff --git a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md b/openspec/changes/delete-builtins-reject-dot-targets/tasks.md index a1b8b62..52866c7 100644 --- a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md +++ b/openspec/changes/delete-builtins-reject-dot-targets/tasks.md @@ -1,6 +1,6 @@ ## 1. Core: reject `.`/`..` delete targets -- [x] 1.1 In `crates/filecommand-core/src/update.rs`, add an early guard at the top of `run_delete_builtin`: a target whose `Path::components()` is exactly `CurDir` or `ParentDir` sets `last_error` to `"{verb}: invalid target {target}"` and returns — before any listed-entry lookup, filesystem check, or dialog. +- [x] 1.1 In `crates/filecommand-core/src/update.rs`, add an early guard at the top of `dispatch_delete_builtin`: a target whose `Path::components()` is exactly `CurDir` or `ParentDir` sets `last_error` to `"{verb}: invalid target {target}"` and returns — before any listed-entry lookup, filesystem check, or dialog. - [x] 1.2 The guard must cover trailing-separator spellings (`..`, `..\`, `.`, `.\`) via the components normalization, and must not reject multi-component relative paths like `..\sibling`. ## 2. Tests diff --git a/openspec/changes/delete-key-triggers-delete/design.md b/openspec/changes/delete-key-triggers-delete/design.md index 75ad8ac..a8f202b 100644 --- a/openspec/changes/delete-key-triggers-delete/design.md +++ b/openspec/changes/delete-key-triggers-delete/design.md @@ -24,14 +24,14 @@ The bug shipped without detection because `input/tests.rs` asserts `F(8) → Req The Delete key is a hard-coded alias for F8, mirroring the existing fixed `Ctrl+Ins` → clipboard-Files alias in the same function (which is deliberately not rebindable). This is the smallest possible surface and matches Norton Commander, where Del maps to the F8 action. Rejected: adding a `delete` field to the `Keys` config struct — more surface (schema, defaults, docs) for no requested benefit. ### D2 — Plain Delete only -The new arm is guarded by the existing `is_plain(&key)` helper so that only an unmodified Delete triggers deletion; a modified Delete press falls through, keeping `Shift+Delete` and `Ctrl+Delete` available for any future meaning. This matches how the plain F8 arm and the other unmodified panel keys behave. +The new arm is guarded by an exact no-modifier check (`key.modifiers == KeyModifiers::NONE`, deliberately stricter than `is_plain`, which still admits Shift) so that only an unmodified Delete triggers deletion; a modified Delete press falls through, keeping `Shift+Delete` and `Ctrl+Delete` available for any future meaning. This matches how the plain F8 arm and the other unmodified panel keys behave. ### D3 — Placement in the match The arm is added among the unmodified navigation/function keys near the `KeyCode::F(8)` arm, i.e. after the ctrl/alt-guarded arms earlier in the function, preserving the file's existing "guarded chords first, then plain keys" ordering. ## Risks / Tradeoffs -Negligible — the change is purely additive and reuses an already-tested command path. The only conceivable risk is shadowing a future modified-Delete chord, which the `is_plain` guard prevents. +Negligible — the change is purely additive and reuses an already-tested command path. The only conceivable risk is shadowing a future modified-Delete chord, which the no-modifier guard prevents. ## Open Questions diff --git a/openspec/changes/delete-key-triggers-delete/tasks.md b/openspec/changes/delete-key-triggers-delete/tasks.md index cae8419..1f28858 100644 --- a/openspec/changes/delete-key-triggers-delete/tasks.md +++ b/openspec/changes/delete-key-triggers-delete/tasks.md @@ -1,6 +1,6 @@ ## 1. Wire the Delete key -- [x] 1.1 In `crates/filecommand-tui/src/input/mod.rs`, add a `KeyCode::Delete if is_plain(&key) => Some(Command::RequestDelete)` arm to `map_panel_key`, placed among the unmodified keys near the existing `KeyCode::F(8)` arm. +- [x] 1.1 In `crates/filecommand-tui/src/input/mod.rs`, add a `KeyCode::Delete if key.modifiers == KeyModifiers::NONE => Some(Command::RequestDelete)` arm to `map_panel_key`, placed among the unmodified keys near the existing `KeyCode::F(8)` arm. ## 2. Regression test diff --git a/openspec/changes/help-flags/design.md b/openspec/changes/help-flags/design.md index 8dd1e96..a7a8d74 100644 --- a/openspec/changes/help-flags/design.md +++ b/openspec/changes/help-flags/design.md @@ -19,7 +19,7 @@ **D1 — Manual argv check, no parsing library** -Check `std::env::args().nth(1)` for the three flag strings (`-h`, `-?`, `--help`). The binary has no other flags today, so pulling in a full parser crate would be disproportionate. If flags grow in future the decision can be revisited. +Check `std::env::args().skip(1)` for any occurrence of the three flag strings (`-h`, `-?`, `--help`). The binary has no other flags today, so pulling in a full parser crate would be disproportionate. If flags grow in future the decision can be revisited. *Alternatives considered:* `clap` — rejected (heavy dependency for a one-flag check); `pico-args` — still a new dep, not justified yet. diff --git a/openspec/changes/help-flags/proposal.md b/openspec/changes/help-flags/proposal.md index 1db33cf..6e7ed6e 100644 --- a/openspec/changes/help-flags/proposal.md +++ b/openspec/changes/help-flags/proposal.md @@ -4,7 +4,7 @@ Users expect `-h`, `-?`, and `--help` to print usage information and exit immedi ## What Changes -- Recognise `-h`, `-?`, and `--help` as the first argument on the command line +- Recognise `-h`, `-?`, and `--help` anywhere on the command line - Print a short usage block to stdout (synopsis, flags, and a pointer to F1 in-app help) - Exit with code 0 before acquiring the terminal, alternate screen, or mouse capture diff --git a/openspec/changes/reread-preserves-cursor/design.md b/openspec/changes/reread-preserves-cursor/design.md index 31d1565..cc53ced 100644 --- a/openspec/changes/reread-preserves-cursor/design.md +++ b/openspec/changes/reread-preserves-cursor/design.md @@ -4,7 +4,7 @@ Every directory read in `filecommand-core` goes through `update::begin_listing` → `begin_listing_inner` → `Panel::begin_new_listing(cwd)` (`crates/filecommand-core/src/panel.rs`). That method resets `cursor = 0`, `scroll_offset = 0`, and `cursor_user_moved = false`, then the worker streams `Command::ListingChunk` events whose `insert_streamed` keeps the cursor pinned to 0 until the user moves (panel-navigation "Streamed listing keeps the top pinned until the user moves"). `Command::ListingComplete` clamps the cursor, reconciles the selection, consumes find-file's `pending_cursor_target` (if any) to settle the cursor on a named entry, and calls `reconcile_panel_viewport` to re-clamp the offset. -That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today: +That reset is right when the panel navigates into a different directory. It is wrong for the three callers that re-read the directory the panel is already showing, all of which reach `begin_listing_inner` today (a fourth caller, the Tree-mode cursor-move preview of the opposite panel, also reaches it and is deliberately excluded — see D5): - `Command::JobDone` — re-reads every active tab whose `cwd` matches the job's source or destination directory (file-operations "Automatic panel re-read on completion"). - `Command::RereadPanel` — Ctrl+R and the Left/Right menu's Re-read item. @@ -44,7 +44,7 @@ Find-file's `pending_cursor_target` is checked first on `ListingComplete` and wi ### D3: Restore the scroll offset, then clamp to the new list -On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple. +On apply, `scroll_offset` is restored from the anchor and then clamped to `visible_len.saturating_sub(rows)` so a list that shrank at the bottom does not leave blank trailing rows (deleting the last visible entry at the end of a long listing pulls the window up by one, as Norton Commander does). The existing `reconcile_panel_viewport` then runs as it does for every other list mutation, so if the panel's body height differs from when the anchor was captured, the cursor-visibility clamp wins — the same rule panel-navigation already applies to tab restore. In Brief mode the offset is a column position, not a row position, so the rows-based clamp is not applied; the restored offset is passed through `ensure_cursor_visible_brief`, which keeps it on a whole-column multiple and clamps by columns. The offset counts rows of the quick-filter-narrowed list, and `begin_new_listing` clears the filter, so when a quick filter was active at capture the scroll offset is **not** restored (it resets to 0); the cursor entry still is, and `reconcile_panel_viewport` scrolls it into view. ### D4: The anchor lives on `Panel` and is cleared by any different-directory navigation @@ -52,7 +52,7 @@ On apply, `scroll_offset` is restored from the anchor and then clamped to `visib ### D5: Capture in `begin_listing_inner`, so every caller benefits without plumbing -`begin_listing_inner` is the single funnel for all three re-read callers and for ordinary navigation. It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. +`begin_listing_inner` is the single funnel for all three re-read callers, for ordinary navigation, and for the Tree-mode preview. The preview is a navigation of the *opposite* panel, not a re-read the user asked for, so it never captures an anchor: the capture is skipped when the caller is the Tree preview (an explicit `capture_anchor: bool` parameter on `begin_listing_inner`, `true` only from `begin_listing`'s re-read paths). It captures the anchor (via `Panel::capture_reread_anchor`) only when the target path equals the panel's current `cwd` **and** the current listing is `Complete`; a re-read issued while the previous listing is still streaming captures nothing and keeps today's behavior rather than anchoring to a half-loaded list. `JobDone`, `RereadPanel`, and the stale-tab path need no changes of their own. ## Risks / Trade-offs diff --git a/openspec/changes/reread-preserves-cursor/tasks.md b/openspec/changes/reread-preserves-cursor/tasks.md index 702ce59..e34b69d 100644 --- a/openspec/changes/reread-preserves-cursor/tasks.md +++ b/openspec/changes/reread-preserves-cursor/tasks.md @@ -3,14 +3,14 @@ ## 1. Core panel state (`crates/filecommand-core/src/panel.rs`) - [ ] 1.1 Add `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` and `Panel::reread_anchor: Option`, initialized to `None` (design D1, D4) -- [ ] 1.2 Include `reread_anchor` in the per-tab stash/restore data beside `pending_cursor_target` so a tab switched away mid-re-read keeps it (design D4) +- [ ] 1.2 Add `reread_anchor` to the `TabData` struct and to `to_tab_data` and its restore counterpart beside `pending_cursor_target`, so a tab switched away mid-re-read keeps it (design D4) - [ ] 1.3 Add `Panel::capture_reread_anchor()` — records entry names in display order, cursor, and scroll offset only when `progress` is `Complete`; otherwise leaves the anchor `None` (design D5) -- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` and clamps it to `visible_len.saturating_sub(rows)`; returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) +- [ ] 1.4 Add `Panel::apply_reread_anchor(rows)` — resolves the cursor in the order `names[cursor]`, `names[cursor + 1..]`, `names[..cursor]` reversed; on a hit sets `cursor` and `cursor_user_moved = true`, restores `scroll_offset` (skipped when a quick filter was active at capture; in Brief mode clamped by columns via `ensure_cursor_visible_brief`, otherwise to `visible_len.saturating_sub(rows)`); returns whether a name resolved (panel-navigation: "Same-directory re-read preserves cursor and scroll position"; design D1, D3) - [ ] 1.5 In `begin_new_listing(cwd)`, clear `reread_anchor` when `cwd != self.cwd` so a navigation never inherits a stale anchor (design D4) ## 2. Reducer (`crates/filecommand-core/src/update.rs`) -- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` (design D5) +- [ ] 2.1 In `begin_listing_inner`, call `capture_reread_anchor()` before `begin_new_listing` when `path == panel.cwd` and the caller is not the Tree-mode preview at update.rs `begin_listing_inner(state, side.toggle(), target)` (design D5) - [ ] 2.2 In `apply_listing_event`, on `ListingChunk` call `apply_reread_anchor(rows)` after the chunk is inserted and before `reconcile_panel_viewport` (panel-navigation: "No jump while the re-read streams"; design D2) - [ ] 2.3 In `apply_listing_event`, on `ListingComplete` apply `pending_cursor_target` first (unchanged), then `apply_reread_anchor(rows)` if no find-file target was consumed, then take the anchor so it is consumed, then `reconcile_panel_viewport` (design D2) - [ ] 2.4 Route the Brief-mode offset restore through `ensure_cursor_visible_brief` so the restored offset stays on a whole-column multiple (design D3) diff --git a/openspec/changes/ui-animations/design.md b/openspec/changes/ui-animations/design.md index 9b6c6f7..666cc80 100644 --- a/openspec/changes/ui-animations/design.md +++ b/openspec/changes/ui-animations/design.md @@ -39,14 +39,14 @@ For the dialog iris and the panel wipe, the view renders the finished surface in - `MenuState { opened_at_ms, pulldown_opened_at_ms, selected_at_ms }` — `pulldown_opened_at_ms` is re-stamped on Left/Right traversal and hotkey jumps (the new pull-down unfolds while open and interactive); `selected_at_ms` on every selection change. - `UserMenuState { opened_at_ms, selected_at_ms }`, `FileActionMenuState { opened_at_ms, selected_at_ms }` — same unfold/settle treatment via one shared pop-up helper. -- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help`/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. +- `State::dialog_opened_at_ms` — set whenever a modal dialog becomes visible (phase enters `FileOpSetup`/`FileOpRunning`/`FileOpSummary`, or `help` (including its About dialog)/`theme_picker`/`drive_select`/`fuzzy_jump`/`find_file` become `Some`, or the startup warning becomes visible). The startup warning is set before the first `Tick`, so it is stamped from the first clock reading the reducer sees rather than at construction, and `MenuState::opened`/`for_menu`/`go_to` take the clock reading as an argument (or are stamped by the reducer immediately after construction). Only one of these is visible at a time, so one stamp suffices; a replacement dialog (e.g. progress → conflict) re-stamps and irises again. - `State::quit_opened_at_ms` — the quit dialog layers over others, so it has its own stamp and the underlying dialog does not re-animate. - `State::panels_revealed_at_ms: Option` — set on the `Splash → Panels` transition only (hold elapsed or key). `None` when the splash was skipped, so frame 1 with `--nosplash` is still the complete panels. - `UiPhase::Splash { started_at_ms }` already exists and drives the splash entrance. ### D5: `State::animations` defaults off in `State::empty`, on in `Config::default` -`Config::default().animations = true` so real launches animate; `apply_config` copies it into `State::animations`; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. +`Config::default().animations = true` so real launches animate; `apply_config` copies the config value into `State::animations`, and `run` applies the `--noanimations` override (alongside the existing `config.splash && !launch.no_splash` computation) because `apply_config` takes no launch options; `State::empty` sets `false`. Every view checks `state.animations` first and short-circuits to the final frame when off. Result: all existing snapshot and reducer tests (which construct `State::empty`) are unchanged; animation snapshots opt in by setting the flag and pinning `clock_ms`. ### D6: Fixed timing budget, all ≤ the existing splash hold @@ -65,7 +65,7 @@ Durations are constants in `core::anim` so tests reference them by name. Everyth ### D7: Disable precedence mirrors `splash`/`mouse` -`animations = false` in the general options of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. +`animations = false` as a top-level key of `config.toml`, or `--noanimations` at launch; the flag wins when they disagree. Parsed in `config::parse` next to `splash` and in `parse_launch_args` next to `--nosplash`; listed in `print_usage` and the README. The design doc §4.10 sentence is superseded for decorative chrome only; this change is the normative record of that. ## Risks / Trade-offs diff --git a/openspec/changes/ui-animations/proposal.md b/openspec/changes/ui-animations/proposal.md index 662de75..1faa916 100644 --- a/openspec/changes/ui-animations/proposal.md +++ b/openspec/changes/ui-animations/proposal.md @@ -12,7 +12,7 @@ The design doc (§4.10) says FileCommand "never uses spinners or animation glyph - **Startup splash entrance**: the double-line frame traces itself clockwise from the top-left corner, the four identity lines reveal outward from their centers (appearing first in the frame color, then settling to their final role), and a single highlight sweep crosses the product name — all inside the existing 800 ms minimum hold. After the splash, the panels wipe in top-to-bottom over 150 ms. - **Menus**: on F9 the menu bar sweeps in left-to-right; every pop-up menu (F9 pull-downs, F2 user menu, Enter file-action menu) unfolds top-down from its frame; when the highlighted item changes, the new highlight row renders one frame in the hotkey accent before settling to the normal highlight style. - **Modal dialogs**: every modal dialog opens with a 100 ms "iris" — a growing centered window onto the finished dialog — instead of appearing fully formed. -- **Configuration**: `animations = true` (default) in `config.toml` general options; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. +- **Configuration**: `animations = true` (default) as a top-level key in `config.toml`; `--noanimations` launch flag; flag wins. When disabled, every surface renders its final frame immediately — pixel-identical to today. - **No new theme roles**: all animation colors come from roles that already exist (`splash.frame`, `splash.version`, `menu.hotkey`, `screen.backdrop`), so user `themes/*.toml` files keep validating. - **Determinism**: animation progress is derived from `State::clock_ms`, so `insta` snapshot tests pin a clock and capture mid-animation frames; states built without config keep animations off, so existing snapshots are unchanged. @@ -30,6 +30,6 @@ The design doc (§4.10) says FileCommand "never uses spinners or animation glyph ## Impact - **Crates:** `filecommand-core` — new `anim` module (progress/easing helpers, duration constants); `opened_at_ms`/`selected_at_ms` stamps on `MenuState`, `UserMenuState`, `FileActionMenuState`; `State::animations` flag, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; reducer sets stamps from `state.clock_ms` where overlays open; `config.rs` `animations` key. `filecommand-tui` — `--noanimations` in `parse_launch_args`, `apply_config` wiring, `print_usage` line; `splash.rs`, `menubar.rs`, `user_menu.rs`, `file_action_menu.rs` interpolated rendering; a shared scratch-buffer "reveal" helper in `views/mod.rs` used by the dialog iris and the panel wipe; new snapshot tests. -- **Docs:** README flag table and `config.toml` key table gain `--noanimations` / `animations`. +- **Docs:** README flag table and `config.toml` key table (the `--help` usage block lists it in its OPTIONS list) gain `--noanimations` / `animations`. - **Depends on:** `startup-splash` (clock + hold), `pulldown-menus`, `user-menu`, `file-action-menu`, `responsive-layout` (overlay geometry), `theme-system` (role-only colors). The `help-flags` change's usage block gains the new flag. - **Out of scope:** animating loading feedback (listings, Info panel, drive select, progress counters); a reduced-motion/"subtle" intermediate level; a runtime Options toggle; sound; ASCII-art logo; per-theme animation overrides. diff --git a/openspec/changes/ui-animations/specs/ui-animations/spec.md b/openspec/changes/ui-animations/specs/ui-animations/spec.md index b759a42..65dbc8a 100644 --- a/openspec/changes/ui-animations/specs/ui-animations/spec.md +++ b/openspec/changes/ui-animations/specs/ui-animations/spec.md @@ -23,7 +23,7 @@ The system SHALL implement every animation as a pure function of the elapsed tim ### Requirement: Animations are disabled by config or launch flag -The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set in `config.toml` general options or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's flag table. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. +The system SHALL animate by default and SHALL render every surface in its final form immediately — identical to the pre-animation rendering — when `animations = false` is set as a top-level key in `config.toml` or the `--noanimations` launch flag is passed. When the flag and config disagree, `--noanimations` SHALL win. The `--noanimations` flag SHALL appear in the `--help` usage block's OPTIONS list. A `State` constructed without configuration (`State::empty`) SHALL have animations disabled so that existing snapshot and reducer tests are unaffected. #### Scenario: Default is animated @@ -43,7 +43,7 @@ The system SHALL animate by default and SHALL render every surface in its final #### Scenario: Flag is documented in usage - **WHEN** the user runs `filecommand --help` -- **THEN** the flag table includes `--noanimations` +- **THEN** the OPTIONS list includes `--noanimations` #### Scenario: States built without config do not animate @@ -116,7 +116,7 @@ The system SHALL, when the splash is replaced by the panels (minimum hold elapse ### Requirement: Loading feedback stays static -The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses animation" statement for decorative chrome only. +The system SHALL NOT animate loading or progress feedback: streaming directory listings, the `Reading… N` mini-status, Info-panel `…` placeholders, drive-select volume-label fill-in, and the progress dialog's counters and byte gauge SHALL continue to render as static text updated in place. Decorative chrome (splash, menus, dialogs) is the only animated surface; this scoping supersedes design doc §4.10's blanket "never uses spinners or animation glyphs" statement for decorative chrome only. #### Scenario: Streaming listing has no spinner diff --git a/openspec/changes/ui-animations/tasks.md b/openspec/changes/ui-animations/tasks.md index 40e61d1..77a0a02 100644 --- a/openspec/changes/ui-animations/tasks.md +++ b/openspec/changes/ui-animations/tasks.md @@ -5,12 +5,12 @@ - [ ] 1.1 Add `filecommand-core::anim` with per-mille `progress`, `reveal_count`, `sweep_pos`, iris-rect helper, and the named duration constants from design D6 (ui-animations: "Render-only motion model driven by the reducer clock") - [ ] 1.2 Add `State::animations`, `State::dialog_opened_at_ms`, `State::quit_opened_at_ms`, `State::panels_revealed_at_ms`; `State::empty` sets `animations = false` (ui-animations: "Animations are disabled by config or launch flag") - [ ] 1.3 Stamp `MenuState` (`opened_at_ms`, `pulldown_opened_at_ms`, `selected_at_ms`), `UserMenuState` and `FileActionMenuState` (`opened_at_ms`, `selected_at_ms`) from `state.clock_ms` in the reducer on open, traversal, and selection change (ui-animations: "Pop-up menus unfold and highlights settle") -- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") +- [ ] 1.4 Stamp `dialog_opened_at_ms` where each modal dialog becomes visible (including the About dialog and the startup warning, stamped on the first `Tick` when set before it) and `quit_opened_at_ms` where `quit_confirm` is set; stamp `panels_revealed_at_ms` on the Splash → Panels transition only (ui-animations: "Modal dialogs open with an iris"; "Panels wipe in after the splash") ## 2. Configuration and launch flag - [ ] 2.1 Parse `animations = ` in `config::parse` (general options; default `true`) (ui-animations: "Animations are disabled by config or launch flag") -- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run`/`apply_config` (ui-animations: "Animations are disabled by config or launch flag") +- [ ] 2.2 Parse `--noanimations` in `parse_launch_args`; flag overrides config in `run` (alongside the existing `config.splash && !launch.no_splash` computation, since `apply_config` takes no launch options) (ui-animations: "Animations are disabled by config or launch flag") - [ ] 2.3 Add `--noanimations` to `main.rs` `print_usage` and to the README flag table; add `animations` to the README config key table (ui-animations: "Animations are disabled by config or launch flag") ## 3. Splash and panel reveal From 8d73efd657b34b5bfebdbaf0d1f042c8895e39e9 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:25:24 -0600 Subject: [PATCH 11/14] Archive help-flags, stale-tab-refresh-skips-history, delete-builtins-reject-dot-targets All three are fully implemented, so their spec deltas are synced into application-shell, panel-tabs, and command-line and the changes move to the archive. delete-key-triggers-delete stays open until its manual check (task 3.3) is done. Co-Authored-By: Claude Sonnet 5.5 --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/command-line/spec.md | 0 .../tasks.md | 0 .../2026-10-09-help-flags}/.openspec.yaml | 0 .../2026-10-09-help-flags}/design.md | 0 .../2026-10-09-help-flags}/proposal.md | 0 .../specs/application-shell/spec.md | 0 .../2026-10-09-help-flags}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/panel-tabs/spec.md | 0 .../tasks.md | 0 openspec/specs/application-shell/spec.md | 34 +++++++++++++++++++ openspec/specs/command-line/spec.md | 6 +++- openspec/specs/panel-tabs/spec.md | 6 +++- 18 files changed, 44 insertions(+), 2 deletions(-) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/.openspec.yaml (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/design.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/proposal.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/specs/command-line/spec.md (100%) rename openspec/changes/{delete-builtins-reject-dot-targets => archive/2026-10-09-delete-builtins-reject-dot-targets}/tasks.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/.openspec.yaml (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/design.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/proposal.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/specs/application-shell/spec.md (100%) rename openspec/changes/{help-flags => archive/2026-10-09-help-flags}/tasks.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/.openspec.yaml (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/design.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/proposal.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/specs/panel-tabs/spec.md (100%) rename openspec/changes/{stale-tab-refresh-skips-history => archive/2026-10-09-stale-tab-refresh-skips-history}/tasks.md (100%) diff --git a/openspec/changes/delete-builtins-reject-dot-targets/.openspec.yaml b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/.openspec.yaml similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/.openspec.yaml rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/.openspec.yaml diff --git a/openspec/changes/delete-builtins-reject-dot-targets/design.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/design.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/design.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/design.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/proposal.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/proposal.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/proposal.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/proposal.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/specs/command-line/spec.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/specs/command-line/spec.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/specs/command-line/spec.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/specs/command-line/spec.md diff --git a/openspec/changes/delete-builtins-reject-dot-targets/tasks.md b/openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/tasks.md similarity index 100% rename from openspec/changes/delete-builtins-reject-dot-targets/tasks.md rename to openspec/changes/archive/2026-10-09-delete-builtins-reject-dot-targets/tasks.md diff --git a/openspec/changes/help-flags/.openspec.yaml b/openspec/changes/archive/2026-10-09-help-flags/.openspec.yaml similarity index 100% rename from openspec/changes/help-flags/.openspec.yaml rename to openspec/changes/archive/2026-10-09-help-flags/.openspec.yaml diff --git a/openspec/changes/help-flags/design.md b/openspec/changes/archive/2026-10-09-help-flags/design.md similarity index 100% rename from openspec/changes/help-flags/design.md rename to openspec/changes/archive/2026-10-09-help-flags/design.md diff --git a/openspec/changes/help-flags/proposal.md b/openspec/changes/archive/2026-10-09-help-flags/proposal.md similarity index 100% rename from openspec/changes/help-flags/proposal.md rename to openspec/changes/archive/2026-10-09-help-flags/proposal.md diff --git a/openspec/changes/help-flags/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-09-help-flags/specs/application-shell/spec.md similarity index 100% rename from openspec/changes/help-flags/specs/application-shell/spec.md rename to openspec/changes/archive/2026-10-09-help-flags/specs/application-shell/spec.md diff --git a/openspec/changes/help-flags/tasks.md b/openspec/changes/archive/2026-10-09-help-flags/tasks.md similarity index 100% rename from openspec/changes/help-flags/tasks.md rename to openspec/changes/archive/2026-10-09-help-flags/tasks.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/.openspec.yaml b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/.openspec.yaml similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/.openspec.yaml rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/.openspec.yaml diff --git a/openspec/changes/stale-tab-refresh-skips-history/design.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/design.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/design.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/design.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/proposal.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/proposal.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/proposal.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/proposal.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/specs/panel-tabs/spec.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/specs/panel-tabs/spec.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/specs/panel-tabs/spec.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/specs/panel-tabs/spec.md diff --git a/openspec/changes/stale-tab-refresh-skips-history/tasks.md b/openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/tasks.md similarity index 100% rename from openspec/changes/stale-tab-refresh-skips-history/tasks.md rename to openspec/changes/archive/2026-10-09-stale-tab-refresh-skips-history/tasks.md diff --git a/openspec/specs/application-shell/spec.md b/openspec/specs/application-shell/spec.md index 0eb21be..03ced60 100644 --- a/openspec/specs/application-shell/spec.md +++ b/openspec/specs/application-shell/spec.md @@ -181,3 +181,37 @@ The system SHALL open a quit-confirmation dialog — never exit directly — whe - **WHEN** a copy/move/delete job is running and the user requests quit via Esc, then confirms - **THEN** the job is aborted through the same path as its cancel action before the application exits +### Requirement: Help flags print usage and exit before TUI startup + +The system SHALL recognise `-h`, `-?`, and `--help` as the sole command-line argument, print a usage block to stdout, and exit with code 0 without acquiring the terminal, alternate screen, raw mode, or mouse capture. The usage block SHALL include: the binary name and version (sourced from `CARGO_PKG_VERSION`), a one-line description, a synopsis line, a flag table listing `-h` / `-?` / `--help`, `--theme `, `--nomouse`, and `--nosplash`, and a line directing the user to press F1 inside the application for the full key reference. + +#### Scenario: -h prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand -h` +- **THEN** the usage block is written to stdout, the process exits with code 0, and the terminal is never placed into raw mode or the alternate screen + +#### Scenario: -? prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand -?` +- **THEN** the usage block is written to stdout and the process exits with code 0 + +#### Scenario: --help prints usage to stdout and exits 0 + +- **WHEN** the user runs `filecommand --help` +- **THEN** the usage block is written to stdout and the process exits with code 0 + +#### Scenario: Usage block includes version and flag table + +- **WHEN** the usage block is printed +- **THEN** it includes the application name, the current version string, a synopsis, entries for `-h` / `-?` / `--help`, `--theme`, `--nomouse`, and `--nosplash`, and a note to press F1 for the full in-app key reference + +#### Scenario: Help flag with additional arguments still prints usage + +- **WHEN** the user runs `filecommand --help --theme nc-classic` +- **THEN** the usage block is printed and the process exits 0 without launching the TUI + +#### Scenario: No help flag — TUI starts normally + +- **WHEN** the application is invoked without `-h`, `-?`, or `--help` +- **THEN** no usage text is printed and the TUI starts as normal + diff --git a/openspec/specs/command-line/spec.md b/openspec/specs/command-line/spec.md index 731fe94..f745619 100644 --- a/openspec/specs/command-line/spec.md +++ b/openspec/specs/command-line/spec.md @@ -138,7 +138,7 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA ### Requirement: del and rmdir route into the existing delete-confirmation flow -`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory. `del` SHALL only accept a target that is a file; `rmdir` SHALL only accept a target that is a directory. A target that doesn't exist, or whose type doesn't match the verb, SHALL be rejected: the panel's error state SHALL be set accordingly and no dialog SHALL open. A valid, type-matched target SHALL open the same delete-confirmation dialog used by F8 and the file-action menu's Delete entry, scoped to that single entry — including the existing non-empty-directory second confirmation for `rmdir` — and SHALL NOT delete anything until that dialog is accepted. +`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory. `.` and `..` are never valid targets — neither names an entry deletable through these verbs — and SHALL be rejected outright: the panel's error state SHALL be set and no dialog SHALL open. `del` SHALL only accept a target that is a file; `rmdir` SHALL only accept a target that is a directory. A target that doesn't exist, or whose type doesn't match the verb, SHALL be rejected: the panel's error state SHALL be set accordingly and no dialog SHALL open. A valid, type-matched target SHALL open the same delete-confirmation dialog used by F8 and the file-action menu's Delete entry, scoped to that single entry — including the existing non-empty-directory second confirmation for `rmdir` — and SHALL NOT delete anything until that dialog is accepted. #### Scenario: del on a file opens the delete-confirmation dialog - **WHEN** the active panel is at `C:\NORTON`, `notes.txt` is a file in it, and the command buffer is `del notes.txt` @@ -160,3 +160,7 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA - **WHEN** the command buffer is `del \NOSUCHFILE.TXT` - **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target was not found +#### Scenario: del/rmdir reject the current and parent directories +- **WHEN** the command buffer is `rmdir ..` (or `del ..`, `rmdir .`, `del .`) +- **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target is invalid — the dialog must never offer to delete the panel's current or parent directory + diff --git a/openspec/specs/panel-tabs/spec.md b/openspec/specs/panel-tabs/spec.md index a6e1fbe..a00cbec 100644 --- a/openspec/specs/panel-tabs/spec.md +++ b/openspec/specs/panel-tabs/spec.md @@ -108,7 +108,7 @@ When the tabs do not all fit on the strip width, labels SHALL shrink stepwise ### Requirement: Stale background tab refresh on activation -A tab marked stale by a completed file-operation job (see `file-operations` — "Automatic panel re-read on completion") SHALL be refreshed with a fresh directory read the moment it becomes the active tab — via Alt+1..9 switch, or via the neighbor activation that follows a Ctrl+W close — instead of displaying its previously cached entries. A tab that is not marked stale SHALL continue to activate from its cached state with no re-read, exactly as today. +A tab marked stale by a completed file-operation job (see `file-operations` — "Automatic panel re-read on completion") SHALL be refreshed with a fresh directory read the moment it becomes the active tab — via Alt+1..9 switch, or via the neighbor activation that follows a Ctrl+W close — instead of displaying its previously cached entries. The deferred refresh is not a navigation: it SHALL re-read the directory but SHALL NOT record a fuzzy-jump history visit for it or persist configuration. A tab that is not marked stale SHALL continue to activate from its cached state with no re-read, exactly as today. #### Scenario: Switching to a stale background tab triggers a fresh read - **WHEN** a background tab is browsing a directory affected by a completed job while it was inactive, and the user activates it with Alt+`n` @@ -122,3 +122,7 @@ A tab marked stale by a completed file-operation job (see `file-operations` — - **WHEN** the user switches to a tab that was not affected by any completed job since it was last active - **THEN** the tab activates from its cached state with no re-read, as before +#### Scenario: The deferred refresh is not a navigation +- **WHEN** a stale background tab is activated and re-reads its directory +- **THEN** the directory is not added to or re-ranked in the fuzzy-jump history, and no configuration file is persisted as a result of the refresh + From 21190fb9e12ae2c31a50ef7d10e33ee65faba7e8 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski MVP Date: Fri, 9 Oct 2026 17:28:41 -0600 Subject: [PATCH 12/14] Archive delete-key-triggers-delete The manual Delete-key check (task 3.3) is confirmed done and validation is clean. The spec delta was already present in operation-dialogs, so the archive only moves the change. Co-Authored-By: Claude Sonnet 5.5 --- .../2026-10-09-delete-key-triggers-delete}/.openspec.yaml | 0 .../2026-10-09-delete-key-triggers-delete}/design.md | 0 .../2026-10-09-delete-key-triggers-delete}/proposal.md | 0 .../specs/operation-dialogs/spec.md | 0 .../2026-10-09-delete-key-triggers-delete}/tasks.md | 4 ++-- 5 files changed, 2 insertions(+), 2 deletions(-) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/.openspec.yaml (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/design.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/proposal.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/specs/operation-dialogs/spec.md (100%) rename openspec/changes/{delete-key-triggers-delete => archive/2026-10-09-delete-key-triggers-delete}/tasks.md (87%) diff --git a/openspec/changes/delete-key-triggers-delete/.openspec.yaml b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/.openspec.yaml similarity index 100% rename from openspec/changes/delete-key-triggers-delete/.openspec.yaml rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/.openspec.yaml diff --git a/openspec/changes/delete-key-triggers-delete/design.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/design.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/design.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/design.md diff --git a/openspec/changes/delete-key-triggers-delete/proposal.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/proposal.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/proposal.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/proposal.md diff --git a/openspec/changes/delete-key-triggers-delete/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/specs/operation-dialogs/spec.md similarity index 100% rename from openspec/changes/delete-key-triggers-delete/specs/operation-dialogs/spec.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/specs/operation-dialogs/spec.md diff --git a/openspec/changes/delete-key-triggers-delete/tasks.md b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md similarity index 87% rename from openspec/changes/delete-key-triggers-delete/tasks.md rename to openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md index 1f28858..8ae0e77 100644 --- a/openspec/changes/delete-key-triggers-delete/tasks.md +++ b/openspec/changes/archive/2026-10-09-delete-key-triggers-delete/tasks.md @@ -10,5 +10,5 @@ ## 3. Verify - [x] 3.1 Run `cargo test -p filecommand-tui` (and the workspace test suite) — all green. -- [ ] 3.2 Run `openspec validate delete-key-triggers-delete --strict` — clean. -- [ ] 3.3 Manually confirm in the running app: cursor on a file, press Delete → the delete-confirmation dialog appears; accept it → the file is deleted; press Delete then Esc → nothing is deleted. +- [x] 3.2 Run `openspec validate delete-key-triggers-delete --strict` — clean. +- [x] 3.3 Manually confirm in the running app: cursor on a file, press Delete → the delete-confirmation dialog appears; accept it → the file is deleted; press Delete then Esc → nothing is deleted. From 99aca5f5289160f6af27672172de74e502460224 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski Date: Thu, 22 Oct 2026 11:32:18 -0600 Subject: [PATCH 13/14] Add OpenSpec proposal auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks each visible panel's directory every refresh_interval seconds (default 60, configurable, 0 = off) on a worker thread by comparing a content digest of its entries with the listing on screen, and re-reads the panel in place — cursor, scroll, selection, and sort kept, no history visit — only when the directory actually changed; a no-change check draws nothing. Adds F12 as a second binding for Ctrl+R's re-read and an eleventh `12Refresh` slot on the F-key bar (77/56/23-column forms). Deltas touch responsive-layout, sort-modes, mouse-input, application-shell, panel-navigation, and configuration-files. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/auto-refresh/.openspec.yaml | 2 + openspec/changes/auto-refresh/design.md | 51 +++++++ openspec/changes/auto-refresh/proposal.md | 48 +++++++ .../specs/application-shell/spec.md | 45 +++++++ .../auto-refresh/specs/auto-refresh/spec.md | 125 ++++++++++++++++++ .../specs/configuration-files/spec.md | 93 +++++++++++++ .../auto-refresh/specs/mouse-input/spec.md | 25 ++++ .../specs/panel-navigation/spec.md | 50 +++++++ .../specs/responsive-layout/spec.md | 20 +++ .../auto-refresh/specs/sort-modes/spec.md | 25 ++++ openspec/changes/auto-refresh/tasks.md | 47 +++++++ 11 files changed, 531 insertions(+) create mode 100644 openspec/changes/auto-refresh/.openspec.yaml create mode 100644 openspec/changes/auto-refresh/design.md create mode 100644 openspec/changes/auto-refresh/proposal.md create mode 100644 openspec/changes/auto-refresh/specs/application-shell/spec.md create mode 100644 openspec/changes/auto-refresh/specs/auto-refresh/spec.md create mode 100644 openspec/changes/auto-refresh/specs/configuration-files/spec.md create mode 100644 openspec/changes/auto-refresh/specs/mouse-input/spec.md create mode 100644 openspec/changes/auto-refresh/specs/panel-navigation/spec.md create mode 100644 openspec/changes/auto-refresh/specs/responsive-layout/spec.md create mode 100644 openspec/changes/auto-refresh/specs/sort-modes/spec.md create mode 100644 openspec/changes/auto-refresh/tasks.md diff --git a/openspec/changes/auto-refresh/.openspec.yaml b/openspec/changes/auto-refresh/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/auto-refresh/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/auto-refresh/design.md b/openspec/changes/auto-refresh/design.md new file mode 100644 index 0000000..fc8a628 --- /dev/null +++ b/openspec/changes/auto-refresh/design.md @@ -0,0 +1,51 @@ +## Context + +See proposal.md — Why. Panels re-read on their own only after FileCommand's jobs, on Ctrl+R / menu Re-read (`Command::RereadPanel` → `begin_listing`), on return from an external editor, and when a stale tab is activated (`refresh_activated_stale_tab` → `begin_listing_inner`). The design doc explicitly left filesystem watching out of v1. The reducer is pure (application-shell "The reducer performs no filesystem I/O"): every read happens on a worker thread and comes back as a `Command`; async replies carry request ids and stale ones are dropped (`listing_request`, `info_request`, `git_request`). The event loop is idle-aware: it wakes only for input, worker results, or a deadline from `anim::next_wake_ms` and the header clock's minute flip, and redraws only when something was applied (application-shell "Idle event loop does not redraw"). + +The F-key bar is a ladder of complete forms chosen by width (`views/keybar.rs`: 67/50/20 columns); clicks map slot numbers through `keybar_command` to the same commands as the keys. + +## Goals / Non-Goals + +**Goals:** +- A panel showing a directory that changed on disk re-reads within about one interval without user action, keeping the user's place. +- Zero cost when nothing changed: no state change, no redraw, no flicker. +- Never interfere with an interaction in progress — in particular never move the cursor under an open confirmation dialog. +- A visible, clickable `12Refresh` slot equivalent to Ctrl+R. +- Interval configurable; `0` disables. + +**Non-Goals:** +- Real-time change notification (a watcher). Polling at a minute granularity is deliberate: it needs no platform API, no extra thread per directory, and no handling of event storms. +- Probing background tabs, Tree/Info/QuickView panels, or the viewer/editor. +- Changing Ctrl+R's navigation bookkeeping (it records a frecency visit today; F12 mirrors it exactly). +- A `--norefresh` launch flag; F11; Ctrl/Alt+F12. + +## Decisions + +**D1 — Detect by content digest, not directory mtime.** On NTFS a directory's own modified time changes when children are added, removed, or renamed, but not when a child's size or content changes; on network shares and FAT it is less reliable still. The probe therefore enumerates the directory with the same reader and classification the listing uses (`StdFsReader`, `classify_entry`) and hashes the entries sorted by name: `(name, kind, size, modified, hidden, link)` plus the count. The reducer keeps the digest of the listing it shows (`PanelState::content_digest`, computed once at `ListingComplete` from `entries` minus `..`, cleared by `begin_new_listing`), so comparing is a `u64` equality — no per-minute O(n) work on the UI thread. Both sides call one `listing::content_digest` so the two digests are comparable by construction; entries the listing skipped as unreadable are skipped identically by the probe. *Alternative considered:* a directory-mtime probe — rejected because it misses in-place modifications, which are exactly what a build or a log writer produces. + +**D2 — Scheduling lives in the reducer clock.** `State::next_probe_ms` is set from `clock_ms + refresh_interval_ms` at startup and after every probe round. `Command::Tick` with `clock_ms >= next_probe_ms` emits `Effect::ProbeDirectory` for each eligible panel (D3), records `probe_request`, and advances `next_probe_ms`. `anim::next_wake_ms` returns `next_probe_ms` as a fourth deadline when `refresh_interval_ms > 0` and at least one panel is eligible, so the idle loop wakes once a minute for it and never otherwise. With `refresh_interval = 0` no deadline exists and nothing changes from today. A suspended session (external editor, Ctrl+O) resumes with a clock jump, so a probe falls due immediately on return — harmless, since the editor return already re-reads the active panel. *Alternative considered:* a TUI-side timer thread — rejected because it would bypass the reducer's single clock and make the behavior untestable in the reducer tests. + +**D3 — Eligibility is checked when a probe is issued and again when its result arrives.** A probe is issued for a panel only when: `phase == UiPhase::Panels`; `overlay.is_none()`; `drag.is_none()`; `quick_search.is_none()`; the panel's `progress` is `Complete` and `last_error.is_none()`; `display_mode` is `Full` or `Brief`; `probe_request.is_none()`. A `DirectoryProbed` is applied only if its `request` matches the panel's `probe_request` and its `path` equals the panel's `cwd`, and every condition above still holds (the request is cleared either way). Re-checking at arrival is what makes the F8 case safe: if a delete confirmation opened between probe and reply, the reply is dropped rather than re-reading under the dialog and letting the cursor land on a different file. The `probe_request.is_none()` guard means a slow share never accumulates probes; the next round simply waits. + +**D4 — A changed directory re-reads through a purpose-named in-place helper.** `begin_listing_inner`'s doc comment says a third non-navigation caller should prompt extracting a helper. `reread_in_place(state, side)` becomes that helper: `begin_listing_inner(side, cwd, capture_anchor = !streaming)` — exactly what `refresh_activated_stale_tab` does today, which now calls it too. The automatic refresh is therefore not a navigation: no `record_visit`, no `PersistHistory`; it does capture the re-read anchor (panel-navigation "Same-directory re-read preserves cursor and scroll position"), keeps the selection set (keyed by identity), keeps the sort mode, and re-issues the git-info query as every re-read does. F12 and the key-bar click, by contrast, dispatch `Command::RereadPanel(active)` and so behave byte-for-byte like Ctrl+R, including its frecency visit. *Alternative considered:* having the probe carry the fresh entries and apply them directly — rejected because it would duplicate the streaming listing's anchor-restore and request-guard logic for a second entry path. + +**D5 — An unchanged probe is invisible.** The reducer applies a matching no-change `DirectoryProbed` by clearing `probe_request` and returning no effects. The TUI's worker-result path marks the frame dirty for every applied command today; it is amended so a `DirectoryProbed` marks it dirty only when the reducer returned at least one effect (i.e. a `StartListing` round began). This keeps application-shell's "Idle screen is not redrawn" true between probes and extends it across a no-change probe. + +**D6 — Probe failure is silent.** `digest: None` (directory unreadable or gone) clears `probe_request` and nothing else. Flipping the panel into the inline read-error state on a timer would be startling and, for a transient share hiccup, wrong; the user's next navigation or Ctrl+R/F12 surfaces the real error through the existing path (filesystem-error-handling "Inline panel read-error state offering re-read or drive change"). + +**D7 — F12 as the eleventh slot; F11 skipped.** NC 5.5's bar had ten slots; F12 reads naturally as "the key past Quit" and is unassigned in every terminal we target. The forms become `1Help … 10Quit 12Refresh` (77 columns — fits the 80-column nominal width), `1Hlp … 10Qit 12Rfr` (56 — fits the 60-column floor), and `1 … 10 12` (23). `hit_slots` emits slot 12 and `keybar_command(12)` returns `RereadPanel(active)`. Drawing an empty `11` slot would waste five columns and invite the question of what F11 does; it is left out. + +**D8 — `refresh_interval` is a top-level integer key.** Parsed next to `panel_split`; accepted values are non-negative integers (seconds). `0` disables. A non-integer or negative value produces the standard line-numbered warning and the default 60 applies, matching how `splash`/`animations` report uninterpretable values. No upper clamp: a user who wants a 10-minute interval can have it. + +## Risks / Trade-offs + +- [Enumeration cost on huge or slow directories] → A probe reads the whole directory once per interval per panel — the same I/O as a non-streaming listing — on a worker thread; the UI thread does a `u64` compare. While the worker runs the loop wakes on its 33 ms cadence as it does for any worker (existing behavior). One outstanding probe per panel (D3); the interval is configurable and can be turned off. +- [A re-read is briefly visible] → The changed panel shows `Reading… N` while it streams, as Ctrl+R does. Acceptable: it only happens when the directory really changed. +- [Digest collision] → A 64-bit hash of the entry tuples could in theory collide, hiding a change until the next real difference. Negligible for a convenience refresh. +- [Clock semantics] → The probe timer runs on the reducer's monotonic clock; a laptop sleep makes one probe fall due on wake. Harmless. +- [Terminal F12 interception] → Windows Terminal, conhost, and common Linux terminals pass F12 through; some IDE-hosted terminals may bind it. Ctrl+R remains, so nothing is lost. +- [Key-bar snapshots] → Every `matrix_keybar_{w}` and panel snapshot that includes the bar changes; they are regenerated as part of the work, which is the cost of any bar change. + +## Open Questions + +None blocking. A later change could surface a one-shot mini-status note ("Refreshed") after an automatic re-read; this proposal keeps it silent, as NC did. diff --git a/openspec/changes/auto-refresh/proposal.md b/openspec/changes/auto-refresh/proposal.md new file mode 100644 index 0000000..53f89c0 --- /dev/null +++ b/openspec/changes/auto-refresh/proposal.md @@ -0,0 +1,48 @@ +## Why + +A panel only re-reads after FileCommand's own operations, on Ctrl+R or the Left/Right menu's Re-read item, or when a stale tab is activated. Anything another program does to the directory on screen — a compiler writing output, a download finishing, a file deleted from another shell — is invisible until the user remembers to press Ctrl+R, a chord that is not shown anywhere on screen. The design doc deferred filesystem watching to v2 and made Ctrl+R the only manual refresh (design doc, "Refresh policy"). This change adds a cheap periodic check that re-reads a panel only when its directory actually changed, and puts a visible `12Refresh` slot on the F-key bar so manual refresh is discoverable. + +## What Changes + +- Every `refresh_interval` seconds (default 60) a worker thread enumerates each eligible panel's current directory and reports a content digest of its direct entries (name, kind, size, modified time, hidden/system flag, link flag). The reducer compares it with the digest of the listing on screen: identical means nothing happens — no state change, no redraw; different means the panel re-reads in place. +- The automatic re-read is the same in-place refresh a stale tab gets on activation: cursor and scroll position are restored, the selection (keyed by entry identity) survives, the sort mode is kept, git info is re-queried, and no fuzzy-jump history visit or configuration write results. +- A probe is only issued — and its result only applied — while the panels are the active screen with no dialog, menu, drag, or type-ahead in progress, and only for a panel whose listing is complete, error-free, and shown in a list display mode (Full or Brief). A probe whose directory cannot be read changes nothing. At most one probe per panel is outstanding at a time. +- `refresh_interval` is a new top-level `config.toml` setting: a non-negative integer number of seconds; `0` turns automatic refresh off; a value that cannot be interpreted produces the standard startup warning and the default (60) applies. +- **F12** re-reads the active panel exactly as Ctrl+R does. The main F-key bar gains an eleventh slot: full form `… 10Quit 12Refresh` (77 columns), short form `… 10Qit 12Rfr` (56 columns), numbers-only `… 10 12` (23 columns). A click on the new slot dispatches the same re-read. Slot 11 is not drawn (F11 stays unassigned). The viewer and editor F-key bars are unchanged. +- The idle event loop treats the next probe as a fourth time-driven wake, and a probe that finds no change does not redraw the screen. +- Non-goals: a filesystem watcher (`notify` / `ReadDirectoryChangesW`); scanning background tabs (they keep the stale-on-activation path); refreshing Tree, Info, or QuickView panels; a launch flag for the interval; any change to Ctrl+R's own behavior. + +## Capabilities + +### New Capabilities + +- `auto-refresh`: periodic directory change detection, the in-place refresh it triggers, the conditions under which it stays out of the way, and the `refresh_interval` setting. + +### Modified Capabilities + +- `responsive-layout`: MODIFIED "F-key bar degradation forms" — the main bar has eleven slots; new form strings and widths. +- `sort-modes`: MODIFIED "Re-read the panel" — F12 is a second binding for the same re-read. +- `mouse-input`: MODIFIED "Key bar, menu bar, pull-down items, and dialog buttons are clickable" — adds the `12Refresh` click scenario. +- `application-shell`: MODIFIED "Idle event loop does not redraw" — the refresh probe is a time-driven wake; a no-change probe result does not redraw. +- `panel-navigation`: MODIFIED "Same-directory re-read preserves cursor and scroll position" — an automatic refresh is one of the re-reads that restore the user's place. +- `configuration-files`: MODIFIED "Malformed or unrecognized configuration lines produce startup warnings" — `refresh_interval` joins the keys whose uninterpretable value is reported. + +## Impact + +- **Core (`crates/filecommand-core`):** + - `update/command.rs` — `Command::DirectoryProbed { panel, path, request, digest: Option }`; `Effect::ProbeDirectory { panel, path, request }`. + - `update.rs` / `anim.rs` — `State::refresh_interval_ms`, `State::next_probe_ms`; `Tick` issues due probes; `anim::next_wake_ms` gains the probe deadline. + - `update/listing.rs` — extract `reread_in_place(state, side)` from `refresh_activated_stale_tab` (the "third caller" `begin_listing_inner`'s doc comment anticipates) and use it for the automatic refresh; apply `DirectoryProbed`. + - `update/overlays.rs` — `keybar_command(12)`. + - `panel.rs` — `PanelState::probe_request: Option`, `PanelState::content_digest: Option` (set at `ListingComplete`, cleared by `begin_new_listing`). + - `listing.rs` — `content_digest(entries)` shared by the listing and the probe; a non-streaming `read_dir_digest` for the worker. + - `config.rs` — `refresh_interval` key, default 60, integer parsing with warning. + - Tests: `update/tests.rs`, `anim.rs`, `panel.rs`, `listing.rs`, `config.rs`. +- **TUI (`crates/filecommand-tui`):** + - `input/mod.rs` — `KeyCode::F(12)` → `Command::RereadPanel(active)` in the panel key map. + - `views/keybar.rs` — eleven-slot forms, widths 77/56/23, `hit_slots` slot 12; `tests/snapshot_matrix.rs` key-bar snapshots regenerate. + - `worker.rs` — `spawn_probe` (enumerate, digest, deliver `DirectoryProbed`). + - `app.rs` — run `Effect::ProbeDirectory`; a `DirectoryProbed` that produces no effects does not set the redraw flag. + - Tests: `input/tests.rs`, `input/mouse/tests.rs`, `tests/snapshot_matrix.rs`, `tests/integration_smoke.rs`. +- **Docs:** `README.md` key table gains `F12` next to Ctrl+R and documents the `refresh_interval` setting; the Help "Keyboard reference" topic text mentions F12. +- **No persisted-format or dependency changes.** `config.toml` gains one optional key. diff --git a/openspec/changes/auto-refresh/specs/application-shell/spec.md b/openspec/changes/auto-refresh/specs/application-shell/spec.md new file mode 100644 index 0000000..39ca0a0 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/application-shell/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Idle event loop does not redraw + +The system SHALL redraw the screen only when something visible may have changed: when input or a worker result that changed state has been applied, or when a time-driven change becomes due — a frame of a running animation, the expiry of clipboard feedback, the end of the splash hold, or a minute boundary of the header clock. The automatic directory check of `auto-refresh` is a time-driven wake that SHALL fall due only at its configured interval, and a check result that finds no change SHALL NOT cause a redraw. While none of these occurs the system SHALL NOT redraw, and SHALL NOT wake more often than about every 33 ms, and then only while worker threads that can still deliver results are running. Time-driven behavior SHALL keep its timing: animations SHALL still advance every frame from the reducer clock until they finish, clipboard feedback SHALL still expire on time without input, and the splash SHALL still end after its hold. Before input is applied after an idle period, the system SHALL bring the reducer clock up to date, so that a surface opened after idling animates from the moment it opened and two clicks separated by idle time are not treated as a double-click. A pending redraw SHALL NOT be lost when the input that follows it produces no command. + +#### Scenario: Idle screen is not redrawn + +- **WHEN** the panels are shown with no input arriving, no worker thread running, no animation in progress, no clipboard feedback shown, no automatic check falling due, and no minute boundary passing for 30 seconds +- **THEN** no frame is drawn during those 30 seconds + +#### Scenario: An unchanged directory check draws nothing + +- **WHEN** the panels sit idle across an automatic directory check whose directories have not changed +- **THEN** the only wake-ups are the check's issue and its result, and no frame is drawn for either + +#### Scenario: Header clock still advances while idle + +- **WHEN** the application is idle and the wall clock passes a minute boundary +- **THEN** the screen is redrawn once at that boundary and the header clock shows the new minute + +#### Scenario: Animations still run after idling + +- **WHEN** the application has been idle for a minute with animations enabled and the user presses F9 +- **THEN** the menu bar sweeps and the `Left` pull-down unfolds over their normal durations with intermediate frames, rather than appearing complete on the first frame + +#### Scenario: Clipboard feedback expires without input + +- **WHEN** clipboard feedback is shown and the user presses no key +- **THEN** the feedback disappears and the mini-status reverts when its expiry time is reached + +#### Scenario: Worker results still appear promptly + +- **WHEN** a directory listing is being read while the user presses no key +- **THEN** each batch of entries is shown within about 33 ms of arriving, and once the listing completes and no worker remains the screen stops redrawing + +#### Scenario: Clicks separated by idle time are not a double-click + +- **WHEN** the user clicks an entry, the application sits idle for 10 seconds, and the user clicks the same entry again +- **THEN** the second click is handled as a single click, not as a double-click + +#### Scenario: Redraw survives an unmapped key + +- **WHEN** a worker result is applied and, in the same loop iteration, the user presses a key that maps to no command +- **THEN** the screen is still redrawn to show the worker result diff --git a/openspec/changes/auto-refresh/specs/auto-refresh/spec.md b/openspec/changes/auto-refresh/specs/auto-refresh/spec.md new file mode 100644 index 0000000..2e5e1ae --- /dev/null +++ b/openspec/changes/auto-refresh/specs/auto-refresh/spec.md @@ -0,0 +1,125 @@ +## ADDED Requirements + +### Requirement: Periodic directory change detection + +While automatic refresh is enabled, the system SHALL check each eligible panel's current directory for changes every `refresh_interval` seconds (default 60) without user input, on a worker thread. An unchanged directory SHALL cause no state change and no redraw; a changed one SHALL re-read as "Automatic refresh is an in-place re-read" specifies. Both panels' active tabs are eligible; background tabs are not checked and keep their stale-on-activation refresh. + +#### Scenario: A file created by another program appears + +- **WHEN** the left panel shows `C:\build` with the cursor on `main.obj`, another program creates `C:\build\app.exe`, and the next automatic check runs +- **THEN** the left panel re-reads, lists `app.exe` in sorted position, and the cursor is still on `main.obj` with the scroll offset unchanged + +#### Scenario: Nothing changed, nothing happens + +- **WHEN** a panel's directory has not changed since its listing completed and an automatic check runs +- **THEN** the panel's entries, cursor, scroll offset, and selection are untouched, no `Reading…` indicator appears, and no frame is drawn as a result of the check + +#### Scenario: Both panels are checked + +- **WHEN** the left panel shows `C:\a` and the right panel shows `D:\b`, both listings are complete, and a check falls due +- **THEN** both directories are checked in the same round, and only a panel whose directory changed re-reads + +#### Scenario: Background tabs are not checked + +- **WHEN** the active panel has a background tab on `C:\logs` and another program writes a file there +- **THEN** no check is made for `C:\logs` while that tab is in the background, and the tab still re-reads on activation only if it was marked stale + +### Requirement: Change detection compares a content digest + +A check SHALL enumerate the directory's direct entries with the same reader and classification the panel listing uses, and SHALL compute a content digest over each entry's name, kind, size, modified time, hidden/system flag, and link flag together with the entry count. The digest SHALL be compared with the digest of the listing the panel shows; the directory counts as changed exactly when the two differ. A check whose directory cannot be read SHALL change nothing. + +#### Scenario: A file modified in place is noticed + +- **WHEN** a panel shows `notes.txt` with size 1,204 and another program appends to it so its size and modified time change, and the next automatic check runs +- **THEN** the panel re-reads and shows the new size and modified time for `notes.txt` + +#### Scenario: A rename is noticed + +- **WHEN** another program renames `draft.txt` to `final.txt` in a panel's directory and the next automatic check runs +- **THEN** the panel re-reads and lists `final.txt` in place of `draft.txt` + +#### Scenario: An unreadable directory is left alone + +- **WHEN** a panel shows a listing of `E:\photos`, the drive is removed, and an automatic check runs +- **THEN** the panel keeps showing its last listing with no error state, and the user's next navigation or Ctrl+R/F12 shows the normal inline read-error state + +### Requirement: One outstanding check per panel + +At most one check per panel SHALL be outstanding at any time. A check that has not answered when the next interval falls due SHALL delay that panel's next check until it has answered; the other panel's checks SHALL continue on schedule. + +#### Scenario: A slow share does not pile up checks + +- **WHEN** the left panel's directory is on a share whose enumeration takes longer than `refresh_interval` and the right panel shows a local directory +- **THEN** no second check for the left panel starts until the first has answered, and the right panel is still checked every interval + +### Requirement: Automatic refresh is an in-place re-read + +A re-read triggered by an automatic check SHALL be the in-place refresh a stale tab receives on activation, not a navigation: cursor and scroll position restored per panel-navigation "Same-directory re-read preserves cursor and scroll position", the identity-keyed selection kept for entries still present, sort mode and direction kept, and the git-info query re-issued. It SHALL NOT record a fuzzy-jump history visit or write any configuration file. + +#### Scenario: Selection survives an automatic refresh + +- **WHEN** `a.txt` and `c.txt` are selected in a panel, another program adds `b.txt`, and the next check re-reads the panel +- **THEN** `a.txt` and `c.txt` are still selected and `b.txt` is not + +#### Scenario: The refresh is not a navigation + +- **WHEN** an automatic check re-reads a panel +- **THEN** the directory's fuzzy-jump frecency entry is not created, re-ranked, or re-stamped, and `history.json` and `config.toml` are not written + +#### Scenario: Sort mode is kept + +- **WHEN** a panel sorted by Size descending is re-read by an automatic check +- **THEN** the refreshed listing is sorted by Size descending and the header still shows the arrow on the Size column + +### Requirement: Automatic refresh never disturbs an interaction + +A check SHALL be issued for a panel only while the panels are the active screen with no dialog, menu, mouse drag, or type-ahead in progress, and only for a panel whose listing is complete without a read error in Full or Brief display mode. A result SHALL be applied only if those conditions still hold on arrival, the panel still shows the checked directory, and the result is the panel's current outstanding check; otherwise it SHALL be dropped and the next interval tries again. + +#### Scenario: A confirmation dialog is never undermined + +- **WHEN** the delete confirmation for `report.txt` is open, another program deletes `report.txt`, and an automatic check falls due or its result arrives +- **THEN** the panel is not re-read while the dialog is open, the dialog still names `report.txt`, and confirming it reports that the file is missing through the normal error path rather than deleting a different file + +#### Scenario: Checks resume after the dialog closes + +- **WHEN** the dialog from the previous scenario is cancelled and the next interval elapses +- **THEN** the check runs, finds the directory changed, and the panel re-reads without `report.txt` + +#### Scenario: A streaming listing is not checked + +- **WHEN** a panel is still showing `Reading… N` for a large directory when a check falls due +- **THEN** no check is issued for that panel in that round + +#### Scenario: Tree, Info, and QuickView panels are skipped + +- **WHEN** the left panel is in Tree mode and the right panel in Full mode and a check falls due +- **THEN** only the right panel's directory is checked + +#### Scenario: Viewer, editor, and running jobs are never interrupted + +- **WHEN** the built-in viewer or editor is open, or a file-operation dialog or job is on screen, and an interval elapses +- **THEN** no check is issued and no panel re-reads until the panels are the active screen again + +### Requirement: Configurable refresh interval + +The system SHALL read `refresh_interval` from `config.toml`'s top-level settings as a non-negative integer number of seconds, defaulting to `60`. `0` SHALL disable automatic checks entirely, leaving the event loop with no refresh deadline. A value that is not a non-negative integer SHALL produce the standard startup warning naming the line, and the default SHALL apply. The interval SHALL be measured from when the previous round of checks was issued. + +#### Scenario: Default interval + +- **WHEN** `config.toml` has no `refresh_interval` line +- **THEN** checks run about every 60 seconds + +#### Scenario: Automatic refresh turned off + +- **WHEN** `config.toml` contains `refresh_interval = 0` +- **THEN** no check is ever issued, and an idle screen with no other deadline draws no frame for as long as it sits idle + +#### Scenario: Custom interval + +- **WHEN** `config.toml` contains `refresh_interval = 15` +- **THEN** checks run about every 15 seconds + +#### Scenario: Uninterpretable value is reported + +- **WHEN** `config.toml` contains `refresh_interval = fast` on line 6 +- **THEN** checks run at the default 60-second interval and the startup-warning dialog names `config.toml` line 6 and states that the value must be a non-negative whole number of seconds diff --git a/openspec/changes/auto-refresh/specs/configuration-files/spec.md b/openspec/changes/auto-refresh/specs/configuration-files/spec.md new file mode 100644 index 0000000..dec9f01 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/configuration-files/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: Malformed or unrecognized configuration lines produce startup warnings + +The system SHALL report each of the following `config.toml` lines as a warning naming the file and the line number, instead of ignoring it silently: + +- a line that is neither blank, a comment, a table header, nor `key = value`; +- a quoted value with no valid closing quote; +- an unrecognized key; +- a recognized key placed under a table where it does not belong, such as a `key.*` binding after `[mouse]`. The warning SHALL state that the setting was ignored because top-level settings must come before the first table; +- an unrecognized table header. One warning for the header SHALL cover the keys beneath it; +- a value of `splash`, `animations`, `refresh_interval`, `[mouse] enabled`, or a `key.*` binding that cannot be interpreted. The default for that key SHALL apply. + +A well-formed `theme` or `panel_split` line whose value is not a built-in theme name or a valid split percentage SHALL keep its existing silent fallback. A `config.toml` that exists but is not valid UTF-8 SHALL produce a warning, and default settings SHALL apply. A missing `config.toml` SHALL NOT produce a warning. + +The warnings that `usermenu.toml` parsing already produces SHALL be reported the same way: an unrecognized key in an entry, a key outside an `[[entry]]` block, and an entry missing `label` or `command`. A `usermenu.toml` that exists but is not valid UTF-8 SHALL be treated as malformed: the startup warning dialog SHALL be raised, the menu SHALL fall back to default entries, and the file SHALL NOT be overwritten. + +The warnings of both files SHALL be collected by the shared tokenizer and its parsers. They SHALL be shown once at startup through the dismissable startup-warning dialog as a single summary: the first warning, in file then line order, followed by the count of further warnings. This summary SHALL NOT replace any other startup warning raised in the same startup, such as a profile-directory warning, the `--theme` override warning, or the malformed-`usermenu.toml` warning; when one of those is raised, it SHALL be shown instead of the summary. When `usermenu.toml` is re-read after the user edits it from within FileCommand, its warnings SHALL be shown the same way. Problems in `history.json` SHALL NOT produce warnings. Warnings SHALL NOT prevent the application from launching. + +#### Scenario: Binding placed after the mouse table is reported + +- **WHEN** `config.toml` contains `[mouse]` on line 8, `enabled = true` on line 9, and `key.fuzzy_jump = "ctrl+g"` on line 11, and the application starts +- **THEN** Ctrl+J still opens the fuzzy jump dialog, and the startup-warning dialog names `config.toml` line 11 and states that `key.fuzzy_jump` is not a `[mouse]` setting and that top-level settings must come before the first table + +#### Scenario: Unknown key is reported + +- **WHEN** `config.toml` contains `colour = "red"` on line 4 together with `splash = false`, and the application starts +- **THEN** the splash is skipped, and the startup-warning dialog names `config.toml` line 4 and the unknown setting `colour` + +#### Scenario: Line without an equals sign is reported + +- **WHEN** `config.toml` contains the line `splash false` on line 2 +- **THEN** the line is ignored, the splash setting keeps its default, and the startup-warning dialog names `config.toml` line 2 + +#### Scenario: Uninterpretable boolean is reported + +- **WHEN** `config.toml` contains `animations = yes` +- **THEN** animations stay enabled (the default), and the startup-warning dialog names that line and states that the value must be `true` or `false` + +#### Scenario: Uninterpretable refresh interval is reported + +- **WHEN** `config.toml` contains `refresh_interval = -5` +- **THEN** automatic refresh runs at the default 60-second interval, and the startup-warning dialog names that line and states that the value must be a non-negative whole number of seconds + +#### Scenario: Several warnings are summarized + +- **WHEN** `config.toml` has three lines that produce warnings and the application starts +- **THEN** the startup-warning dialog shows the warning for the earliest of those lines followed by an indication that 2 more warnings exist + +#### Scenario: Clean configuration produces no warning + +- **WHEN** `config.toml` and `usermenu.toml` contain only recognized settings, comments, and blank lines +- **THEN** no startup-warning dialog is shown for configuration content + +#### Scenario: Unknown theme keeps its silent fallback + +- **WHEN** `config.toml` contains `theme = "no-such-theme"` +- **THEN** the active theme is `nc-classic` and no configuration warning is raised for that line + +#### Scenario: Unterminated quote is reported + +- **WHEN** `config.toml` contains `theme = "purple-lights` on line 3, with no closing quote +- **THEN** the active theme is `nc-classic`, and the startup-warning dialog names `config.toml` line 3 + +#### Scenario: Non-UTF-8 config.toml is reported + +- **WHEN** `config.toml` exists but contains a byte sequence that is not valid UTF-8, such as an ANSI-encoded `é` +- **THEN** the application starts with default settings and the startup-warning dialog states that `config.toml` is not UTF-8 text + +#### Scenario: Non-UTF-8 usermenu.toml is kept, not overwritten + +- **WHEN** `usermenu.toml` exists but is saved as UTF-16 or contains a byte sequence that is not valid UTF-8, and the application starts +- **THEN** the startup-warning dialog is shown, the F2 menu falls back to the default entries, and `usermenu.toml` on disk is byte-for-byte unchanged + +#### Scenario: Unknown user-menu key is shown + +- **WHEN** an `[[entry]]` in `usermenu.toml` has `label`, `command`, and an unrecognized key `icon` on line 5, and the application starts +- **THEN** the entry appears in the F2 menu, and the startup-warning dialog names `usermenu.toml` line 5 and the unknown key `icon` + +#### Scenario: Warnings after editing the user menu + +- **WHEN** the user edits `usermenu.toml` from within FileCommand, adds an entry with an unrecognized key, and returns to the panels +- **THEN** the edited entries replace the F2 menu, and the warning dialog names `usermenu.toml` and the line of the unrecognized key + +#### Scenario: Profile warning keeps precedence + +- **WHEN** the profile directory cannot be created at startup +- **THEN** the startup-warning dialog shows the unwritable-profile warning, not a configuration warning + +#### Scenario: Theme override warning keeps precedence + +- **WHEN** `config.toml` contains an unknown setting and the application is launched with `--theme no-such-theme` +- **THEN** the startup-warning dialog shows the `--theme` warning naming `no-such-theme`, not the configuration warning summary diff --git a/openspec/changes/auto-refresh/specs/mouse-input/spec.md b/openspec/changes/auto-refresh/specs/mouse-input/spec.md new file mode 100644 index 0000000..ce38640 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/mouse-input/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Key bar, menu bar, pull-down items, and dialog buttons are clickable + +A left-click on a function-key-bar slot SHALL dispatch the same command as that function key; a click on a menu-bar title SHALL open that pull-down; a click on an enabled pull-down item SHALL activate it; a click outside an open pull-down SHALL close it; a click on a dialog button SHALL activate that button. + +#### Scenario: Key bar Copy + +- **WHEN** the user clicks the `5Copy` slot +- **THEN** the destination-input dialog opens exactly as for F5 + +#### Scenario: Key bar Refresh + +- **WHEN** the user clicks the `12Refresh` slot (or `12Rfr` / `12` in a narrower form) +- **THEN** the active panel re-reads exactly as for F12 and Ctrl+R + +#### Scenario: Menu item activation + +- **WHEN** the user clicks `Files` in the menu bar and then clicks `Delete` +- **THEN** the delete-confirmation flow starts exactly as for F8 + +#### Scenario: Dialog button + +- **WHEN** the overwrite-conflict dialog is open and the user clicks `Skip All` +- **THEN** the choice is applied exactly as if selected by keyboard diff --git a/openspec/changes/auto-refresh/specs/panel-navigation/spec.md b/openspec/changes/auto-refresh/specs/panel-navigation/spec.md new file mode 100644 index 0000000..bc3bb35 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/panel-navigation/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Same-directory re-read preserves cursor and scroll position + +When a panel re-reads the directory it is already displaying — after a file-operation job completes or is cancelled, on Ctrl+R, F12, or the Left/Right menu's Re-read item, on a stale background tab's activation refresh, or on an automatic refresh after a directory check found a change — the system SHALL restore the cursor to the entry it was on if that entry is still listed; otherwise to the first surviving entry at or after the cursor's previous row; otherwise to the nearest surviving entry before it. The scroll offset SHALL be restored to its previous value and then re-clamped so the window does not extend past the end of the new list and the cursor remains visible. The restored position SHALL hold while the re-read streams in — the cursor SHALL NOT transiently jump to the first entry — and after completion. Navigation into a different directory SHALL continue to reset the cursor and offset to the top. For same-directory re-reads this requirement takes precedence over "Streamed listing keeps the top pinned until the user moves". + +#### Scenario: Deleting the cursored file lands on the next entry + +- **WHEN** the cursor is on `b.txt` in a listing `a.txt`, `b.txt`, `c.txt` and the user confirms a delete of `b.txt` that completes +- **THEN** after the automatic re-read the cursor is on `c.txt` and the scroll offset is unchanged + +#### Scenario: Deleting the last entry lands on the previous one + +- **WHEN** the cursor is on the last entry of a listing and the user confirms a delete of that entry that completes +- **THEN** after the re-read the cursor is on the entry that preceded it, now the last entry + +#### Scenario: Multi-selection delete lands on the first survivor at or after the cursor + +- **WHEN** a listing `a`, `b`, `c`, `d`, `e` has `b`, `c`, `d` selected with the cursor on `c`, and a delete of the selection completes +- **THEN** after the re-read the cursor is on `e` + +#### Scenario: Scrolled window stays put + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 30 and the cursor on position 40, and a delete of the cursored entry completes +- **THEN** after the re-read the scroll offset is still 30 and the cursor is on position 40, which is the entry formerly at position 41 + +#### Scenario: Deleting at the very bottom pulls the window up by one + +- **WHEN** a 60-entry listing is shown in a 20-row body with scroll offset 40 and the cursor on the last entry, and a delete of that entry completes +- **THEN** after the re-read the list has 59 entries, the scroll offset is re-clamped to 39, the cursor is on the new last entry, and no blank trailing row is shown + +#### Scenario: Ctrl+R keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and the user presses Ctrl+R +- **THEN** after the re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: Automatic refresh keeps the cursor's entry + +- **WHEN** the cursor is on `report.txt` partway down a listing and an automatic directory check finds that a file was added elsewhere in the directory +- **THEN** after the automatic re-read the cursor is on `report.txt` and the scroll offset is unchanged + +#### Scenario: No jump while the re-read streams + +- **WHEN** a same-directory re-read is in progress and its first streamed chunk contains the anchored entry +- **THEN** the cursor is on that entry as soon as the chunk is applied, not on the first entry + +#### Scenario: Navigating to a different directory still starts at the top + +- **WHEN** the cursor is partway down a listing and the user presses Enter on a subdirectory +- **THEN** the new listing starts with the cursor on its first entry and the scroll offset at 0 diff --git a/openspec/changes/auto-refresh/specs/responsive-layout/spec.md b/openspec/changes/auto-refresh/specs/responsive-layout/spec.md new file mode 100644 index 0000000..8b6d7e3 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/responsive-layout/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: F-key bar degradation forms + +The system SHALL render the F-key bar in the widest of three canonical forms that fits the terminal width, never truncating a slot mid-label: the full form `1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit 12Refresh` (77 columns), the short form `1Hlp 2Mnu 3Vew 4Edt 5Cpy 6Ren 7Mkd 8Del 9Pdn 10Qit 12Rfr` (56 columns), and the numbers-only form `1 2 3 4 5 6 7 8 9 10 12` (23 columns). All eleven slots SHALL always be present in whichever form renders; no slot numbered 11 is drawn. The same widest-form-that-fits rule SHALL govern the Ctrl and Alt modifier label variants and the viewer and editor F-key bars, each with its own short-form labels of the same shape; those bars gain no twelfth slot. + +#### Scenario: Full form at nominal width + +- **WHEN** the terminal is 80 columns wide +- **THEN** the key bar renders the full form with all eleven labeled slots, ending in `12Refresh` + +#### Scenario: Short form at the floor + +- **WHEN** the terminal is 60 columns wide +- **THEN** the key bar renders the short form `1Hlp 2Mnu 3Vew 4Edt 5Cpy 6Ren 7Mkd 8Del 9Pdn 10Qit 12Rfr` with all eleven slots visible + +#### Scenario: No mid-label truncation + +- **WHEN** the terminal width falls between two forms' required widths +- **THEN** the narrower form renders in full; no slot is ever partially drawn diff --git a/openspec/changes/auto-refresh/specs/sort-modes/spec.md b/openspec/changes/auto-refresh/specs/sort-modes/spec.md new file mode 100644 index 0000000..9fd6062 --- /dev/null +++ b/openspec/changes/auto-refresh/specs/sort-modes/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Re-read the panel + +Ctrl+R and F12 SHALL each re-read the active panel's directory, replacing its entry list with a fresh streaming read while preserving the panel's current sort mode. The two keys SHALL dispatch the identical command, so everything specified for Ctrl+R anywhere holds for F12. The re-read MUST use the same streaming read path as the initial listing, so a large directory fills progressively and shows `Reading… N` in the mini-status until the read completes. + +#### Scenario: Ctrl+R re-reads the directory + +- **WHEN** Ctrl+R is pressed on a focused panel +- **THEN** the panel discards its current entries and begins a fresh streaming read of the same directory + +#### Scenario: F12 re-reads the directory + +- **WHEN** F12 is pressed on a focused panel +- **THEN** the panel re-reads exactly as it does for Ctrl+R, keeping the cursor's entry and scroll offset + +#### Scenario: Re-read preserves sort mode + +- **WHEN** the panel sort mode is Size and Ctrl+R is pressed +- **THEN** the freshly read entries are sorted by Size and the header still shows the sort arrow on the Size column + +#### Scenario: Re-read of a large directory streams with a running count + +- **WHEN** Ctrl+R is pressed on a directory large enough to stream in chunks +- **THEN** the mini-status shows `Reading… N` with N updating as chunks arrive, reverting to the normal mini-status display when the read completes diff --git a/openspec/changes/auto-refresh/tasks.md b/openspec/changes/auto-refresh/tasks.md new file mode 100644 index 0000000..40be1c5 --- /dev/null +++ b/openspec/changes/auto-refresh/tasks.md @@ -0,0 +1,47 @@ +## 1. Core: configuration + +- [ ] 1.1 Add `refresh_interval: u32` (seconds, default 60) to `Config` in `crates/filecommand-core/src/config.rs`; parse it as a non-negative integer next to `panel_split`; an uninterpretable value yields a line-numbered warning and the default (configuration-files "Malformed or unrecognized configuration lines produce startup warnings"). +- [ ] 1.2 Thread it into `State` as `refresh_interval_ms` (`0` = disabled); `State::empty` disables it so existing reducer and snapshot tests are unaffected. +- [ ] 1.3 Unit tests: default, `0`, a positive value, and the `refresh_interval = fast` / `-5` warnings. + +## 2. Core: content digest + +- [ ] 2.1 Add `listing::content_digest(entries)` hashing `(name, kind, size, modified, hidden, link)` over entries sorted by name, plus the count; the synthetic `..` is excluded. +- [ ] 2.2 Add `listing::read_dir_digest(reader, path, cancel) -> io::Result` — a non-streaming enumeration through the same `classify_entry` path, skipping unreadable entries exactly as the listing does. +- [ ] 2.3 `PanelState::content_digest: Option` — computed at `ListingComplete`, cleared by `begin_new_listing`; tests that identical entry sets in different arrival order digest equally and that a change to any hashed field differs. + +## 3. Core: probe scheduling and application + +- [ ] 3.1 Add `Command::DirectoryProbed { panel, path, request, digest: Option }` and `Effect::ProbeDirectory { panel, path, request }` in `update/command.rs`. +- [ ] 3.2 Add `State::next_probe_ms` and `PanelState::probe_request`; on `Tick`, when due and `refresh_interval_ms > 0`, issue a probe for each eligible panel (design D3) and advance `next_probe_ms`. +- [ ] 3.3 Extend `anim::next_wake_ms` with the probe deadline (only when enabled and some panel is eligible). +- [ ] 3.4 Extract `reread_in_place(state, side)` in `update/listing.rs` from `refresh_activated_stale_tab`; make the latter call it; update `begin_listing_inner`'s doc comment. +- [ ] 3.5 Apply `DirectoryProbed`: drop on request/path mismatch or ineligibility; `None` or an equal digest → clear `probe_request`, no effects; a differing digest → `reread_in_place`. +- [ ] 3.6 Reducer tests: a probe is issued once per interval for both eligible panels; not issued while an overlay, viewer, editor, file-op dialog/job, drag, or type-ahead is active, for a streaming or errored panel, or for Info/Tree/QuickView; one outstanding probe per panel; a result is dropped when a dialog opened meanwhile; a no-change result yields no effects; a changed result starts a listing with the anchor captured and emits no `PersistHistory`; `refresh_interval_ms == 0` never probes; `next_wake_ms` reports the deadline. + +## 4. Core: F12 slot command + +- [ ] 4.1 `keybar_command(12)` → `Command::RereadPanel(active)` in `update/overlays.rs` (resolve the active side at the call site or pass it in), with a test. + +## 5. TUI: key, bar, click + +- [ ] 5.1 `KeyCode::F(12)` → `Command::RereadPanel(active)` in `input/mod.rs`'s panel key map; test alongside the Ctrl+R test in `input/tests.rs`. +- [ ] 5.2 `views/keybar.rs`: add `("12", "Refresh")`, `("12", "Rfr")`, `("12", "")` to the three forms; update the width tests to 77/56/23; `hit_slots` yields slot 12; the drag relabel is unchanged. +- [ ] 5.3 Mouse test: a click on the `12Refresh` rect dispatches `KeybarPress(12)` and the reducer turns it into a re-read of the active panel. +- [ ] 5.4 Regenerate the `tests/snapshot_matrix.rs` key-bar snapshots and any panel snapshots that include the bar; confirm 60 columns renders `… 10Qit 12Rfr` in full and 66 columns still renders the short form. + +## 6. TUI: worker and event loop + +- [ ] 6.1 `worker::spawn_probe(panel, path, request, cancel, tx, token)` runs `read_dir_digest` and delivers `DirectoryProbed` (`digest: None` on error); it never delivers after cancellation. +- [ ] 6.2 `app.rs` runs `Effect::ProbeDirectory`; a `DirectoryProbed` that yields no effects does not set the redraw flag (design D5). +- [ ] 6.3 Integration test (`tests/integration_smoke.rs`): with a 1-second interval, create a file externally → the panel lists it after the next probe with the cursor on its previous entry; with nothing changed, no frame is drawn across a probe. + +## 7. Docs + +- [ ] 7.1 `README.md`: key table row `Ctrl+R / F12 | Re-read (refresh) the active panel`; the configuration section documents `refresh_interval` (seconds, default 60, `0` off) and the automatic refresh. +- [ ] 7.2 The Help "Keyboard reference" topic text mentions F12 next to Ctrl+R. + +## 8. Verification + +- [ ] 8.1 `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` all pass. +- [ ] 8.2 Manual: run at 80×24 and 60×16 and check the bar; press F12 and click `12Refresh`; set `refresh_interval = 5`, touch a file from another shell, see it appear with the cursor in place; open F8 on a file, delete it externally, wait a probe — the dialog's target does not change. From f58e364bed66c18f46ffe1b4ec687709ba0906e6 Mon Sep 17 00:00:00 2001 From: Kevin Kaminski Date: Tue, 3 Nov 2026 14:13:07 -0600 Subject: [PATCH 14/14] Add OpenSpec proposal path-box Replaces the command line above the F-key bar with two per-panel path boxes that show each panel's directory, Tab-complete folder and file names anywhere on disk, and on Enter land the panel cursor on the named entry. Retires the command-line capability (cd/del/rmdir builtins, command history, paste-name/path, clear-line), re-homes Ctrl+O and the suspended shell run under application-shell, and adds delta specs for the twelve other capabilities that mention the command line. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/path-box/.openspec.yaml | 2 + openspec/changes/path-box/design.md | 53 +++ openspec/changes/path-box/proposal.md | 50 ++ .../path-box/specs/application-shell/spec.md | 437 ++++++++++++++++++ .../path-box/specs/command-line/spec.md | 51 ++ .../path-box/specs/file-action-menu/spec.md | 62 +++ .../specs/filesystem-error-handling/spec.md | 43 ++ .../path-box/specs/help-and-about/spec.md | 30 ++ .../path-box/specs/panel-navigation/spec.md | 75 +++ .../changes/path-box/specs/path-box/spec.md | 265 +++++++++++ .../path-box/specs/pulldown-menus/spec.md | 25 + .../path-box/specs/quick-filter/spec.md | 45 ++ .../path-box/specs/responsive-layout/spec.md | 44 ++ .../path-box/specs/startup-splash/spec.md | 21 + .../path-box/specs/text-field-editing/spec.md | 84 ++++ .../path-box/specs/theme-selection/spec.md | 26 ++ .../path-box/specs/type-ahead-jump/spec.md | 69 +++ .../changes/path-box/specs/user-menu/spec.md | 15 + openspec/changes/path-box/tasks.md | 41 ++ 19 files changed, 1438 insertions(+) create mode 100644 openspec/changes/path-box/.openspec.yaml create mode 100644 openspec/changes/path-box/design.md create mode 100644 openspec/changes/path-box/proposal.md create mode 100644 openspec/changes/path-box/specs/application-shell/spec.md create mode 100644 openspec/changes/path-box/specs/command-line/spec.md create mode 100644 openspec/changes/path-box/specs/file-action-menu/spec.md create mode 100644 openspec/changes/path-box/specs/filesystem-error-handling/spec.md create mode 100644 openspec/changes/path-box/specs/help-and-about/spec.md create mode 100644 openspec/changes/path-box/specs/panel-navigation/spec.md create mode 100644 openspec/changes/path-box/specs/path-box/spec.md create mode 100644 openspec/changes/path-box/specs/pulldown-menus/spec.md create mode 100644 openspec/changes/path-box/specs/quick-filter/spec.md create mode 100644 openspec/changes/path-box/specs/responsive-layout/spec.md create mode 100644 openspec/changes/path-box/specs/startup-splash/spec.md create mode 100644 openspec/changes/path-box/specs/text-field-editing/spec.md create mode 100644 openspec/changes/path-box/specs/theme-selection/spec.md create mode 100644 openspec/changes/path-box/specs/type-ahead-jump/spec.md create mode 100644 openspec/changes/path-box/specs/user-menu/spec.md create mode 100644 openspec/changes/path-box/tasks.md diff --git a/openspec/changes/path-box/.openspec.yaml b/openspec/changes/path-box/.openspec.yaml new file mode 100644 index 0000000..ac359ee --- /dev/null +++ b/openspec/changes/path-box/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-03 diff --git a/openspec/changes/path-box/design.md b/openspec/changes/path-box/design.md new file mode 100644 index 0000000..aace10e --- /dev/null +++ b/openspec/changes/path-box/design.md @@ -0,0 +1,53 @@ +## Context + +See proposal.md — Why. Today `State.command_line: LineEdit` is the default sink for printable keys over the panels; `map_panel_key` computes `typing = !command_line.is_empty()` and flips the meaning of Backspace, Up/Down, Left/Right and `+`/`-`/`*` on it, and `handle_enter` runs the buffer before acting on the cursor entry. The reducer is pure (application-shell "The reducer performs no filesystem I/O"): `cd` answers from the panel listing when it can and otherwise emits `Effect::ProbePath` with a request id, applied by `apply_path_probed` only when still current. Find-file already navigates a panel and lands the cursor on a named entry through `PanelState::pending_cursor_target`, settled at `ListingComplete`. Type-ahead matches names case-insensitively by prefix on `Entry::sort_key()`. The destination dialog and `cd` share `resolve_cd_target`. `draw_text_field` renders every dialog text field; `render_command_line` already left-truncates a long prompt with `…`. + +## Goals / Non-Goals + +**Goals:** +- Reach any folder or file on disk by typing a prefix and pressing Tab a few times, then Enter. +- Land the panel cursor on the named entry, including a file, not just enter folders. +- Never change what a panel key does based on hidden text state; the panels keep their NC key map. +- Pure reducer, no UI-thread I/O; stale replies dropped as everywhere else. + +**Non-Goals:** +- A popup candidate list, completion history, fuzzy matching, or completing anything but the last segment. +- Making plain printable keys do something over the panels (a follow-up could start type-ahead). +- Any shell. Ctrl+O and the Run / F2 suspended spawn move specs but do not change. + +## Decisions + +**D1 — The path box is a panels-level input mode, not an overlay.** `State.path_box: Option, probe: Option }>` lives beside `quick_search` and the quick filter in the panels layer (application-shell precedence order item 6). While it is `Some`, the TUI routes every key through `map_path_box_key` before `map_panel_key`; keys the box does not bind are ignored, so no F-key, Alt+letter or Ctrl+chord reaches the panels and the box can never be left open under a menu it opened itself. If anything above the panels in the precedence order opens for another reason (a worker-driven dialog, the startup warning, a file-operation dialog), the box is closed and reset, as a type-ahead ends. *Alternative considered:* an `Overlay::PathBox` — rejected because overlays hide the terminal cursor, exclude the menu bar's hit map, and the box must coexist with the panels' rendering and mini-status. + +**D2 — Focus by chord or click; Tab stays the panel switch.** `Keys.focus_path` defaults to Ctrl+G (Ctrl+L is info mode; Ctrl+Y is freed by this change but reads as "clear", not "go"). A click on either box dispatches `PathBoxFocus(side)`, which also makes that panel active so the box, cursor and mini-status agree. On focus the text is `cwd` plus the platform separator with the caret at the end (`LineEdit::set_text_caret_end`). Inside the box: `map_text_edit_key` (the full dialog set), Ctrl+V and Shift+Ins → `PathBoxPaste`, Tab → `PathBoxComplete { reverse: false }`, Shift+Tab (`BackTab`) → `{ reverse: true }`, Enter → `PathBoxAccept`, Esc → `PathBoxCancel`. A mouse click outside the focused box dispatches `PathBoxCancel` and is then handled normally. + +**D3 — Completion state is derived from the text and cached per folder.** On Tab the reducer splits the text at the last separator into `(folder_text, prefix)`, resolves `folder_text` with `resolve_cd_target` against the box's panel `cwd` (so `..\`, `D:x\`, `\\srv\share\` and relative text work) and compares it with `completion.dir`. If equal and `names` is loaded it advances the cycle; otherwise it starts a new completion: if the folder equals either panel's `cwd` and that listing is `Complete`, the names come from that listing at once; else it emits `Effect::ListCompletions { request, dir }` and the mini-status shows `Reading…` until `Command::CompletionsListed { request, dir, names: Option> }` arrives. `names` holds every readable child, no `..`; the worker reads through `FsReader::read_dir` in a new `listing::list_children_for_completion`. Candidates are the names whose `sort_key()`-style lowercase form starts with the lowercase prefix, ordered by `(is_file, lowercase name)` — directories first, then files. This is a small comparator of its own: the panel's `cmp_entries` / `cmp_by_name` deliberately interleave directories and files (classic NC order) and are not reused here. The prefix used for matching is frozen when the cycle starts so repeated Tabs cycle instead of narrowing. Applying a candidate replaces the last segment with the on-disk name and appends a separator for a directory. Any text mutation other than Tab/Shift+Tab sets `index = None` but keeps `names` while `dir` is unchanged, so backspacing and retyping do not re-read. A reply whose `request` is not outstanding, or whose `dir` differs from the folder the current text names, is discarded; Tab during an outstanding read is ignored; `names: None` (unreadable) yields `no matches`. + +**D4 — Enter reuses the cd probe and the find-file cursor landing.** `PathBoxAccept` resolves the whole text (ignoring one trailing separator) with `resolve_cd_target`. If it equals the panel `cwd` → close the box. If `parent_path` is `None` (drive root, UNC share root, `/`) → `begin_listing(side, path)`, close. Otherwise `(parent, name)`: when `parent` equals the box's panel `cwd` and its complete listing holds an entry matching `name` case-insensitively → immediate; when it equals the other panel's complete listing → likewise; else `Effect::ProbePath` with `ProbeIntent::Select { parent, name }` and `pending_note = Checking …`. The reply is applied only if `path_box` is still focused on that side with the same text and the probe id is current; otherwise it is dropped silently. On success: if `parent != cwd`, `begin_listing(side, parent)` then `pending_cursor_target = Some(name)`; when already there, move the cursor directly with the same lookup `jump_to_prefix` uses, clearing a quick filter that hides the target; close the box. On failure: `last_error` names the path (filesystem-error-handling "Error messages name the affected path"); the box keeps focus and text. `ProbeIntent::{Cd, Delete}` are deleted; `PendingProbe { request, side, cwd, path, intent }` keeps its shape. + +**D5 — `pending_cursor_target` matches case-insensitively as a fallback.** `ListingComplete` first looks for an exact `OsString` match (find-file's existing contract), then for the first entry whose `sort_key()` equals the lowercased target. A probe-confirmed `readme.MD` then lands on `README.md` without the worker canonicalising the name. Find-file is unaffected because its names come from disk. + +**D6 — Rendering splits the row at the panel divider and reuses the field renderer.** `layout::compute` keeps `cmdline` as the row above the key bar (core's `panel_viewport_rows` is untouched); the view derives two rects from the left and right panel rects on that row. An unfocused box draws `` in `Role::CommandLine`, left-truncated with `…` when wider than the box. `left_truncate_with_ellipsis` exists as a private function in both `views/command_line.rs` and `views/header.rs`; it is promoted to one shared helper in `views/mod.rs` and applied to the whole box width rather than half the row. The focused box draws its `LineEdit` through `draw_text_field` in the dialog text-field style and returns the caret, which becomes `RenderOutput.cursor`; with no focused box and no dialog field the terminal cursor is hidden. The mini-status override of the box's panel shows `k/n matches`, `no matches` or `Reading…` while a completion is active, in the slot the type-ahead pattern uses today. The hit map records `path_boxes: [Rect; 2]`. + +**D7 — Keys over the panels lose their "typing" dependence.** `map_panel_key` drops `typing`: Backspace is always `ParentDir`, Up/Down always move the cursor, Left/Right are unbound, `+`/`-`/`*` always group-select, Ctrl+Backspace/Ctrl+H are unbound, Ctrl+V and bracketed paste over unfocused panels are ignored, and a plain printable key over the panels is ignored (no sink). Esc keeps requesting quit over the panels; inside a focused box it cancels the box. Alt+letter type-ahead and the Ctrl+P quick filter are unchanged; focusing the box ends type-ahead. + +**D8 — History: the file keeps its shape, the app stops using commands.** `HistoryFile { commands, directories }` and the merge-on-write writer stay, so two instances still preserve each other's data and the Ctrl+J directory history is untouched. `State.history` / `history_cursor`, `config::push_history`, the Alt+F8 dialog, the Commands → History item and `MenuAction::History` are removed; `Effect::PersistHistory(HistoryFile)` keeps its shape, but the `HistoryFile` it carries always has an empty `commands`; the writer's existing merge (`merge_history_files`, fuzzy-jump "Two instances keep each other's history") keeps the file's `commands` array through unchanged. + +**D9 — Config keys.** `key.focus_path` joins the overridable bindings (default `ctrl+g`). `key.paste_name`, `key.paste_path` and `key.clear_line` are no longer recognised, so an old `config.toml` that sets them gets the existing unrecognised-key startup warning (configuration-files "Malformed or unrecognized configuration lines produce startup warnings") — honest, and it tells the user what to delete. No migration. + +**D10 — Interaction with `auto-refresh` (in flight).** Completion also treats a streaming listing as unavailable (path-box "Completion candidates come from a panel listing when possible"): only a `Complete` listing answers, so a partial listing never yields a partial candidate set. Its design D3 eligibility list (no overlay, drag or type-ahead) gains "no focused path box", so an automatic re-read never replaces the listing a completion cycle was answered from. Whichever change is built second adds the guard. + +**D11 — Scenario titles that still say "command line" are kept on purpose.** `openspec validate` rejects a MODIFIED block that drops or renames a scenario, so the restated requirements keep titles such as "Backspace on empty command line goes to the parent" and "Non-empty command buffer takes precedence" while their bodies describe the path box. A later housekeeping change may retitle them directly in the main specs. + +## Risks / Trade-offs + +- [Breaking for `cd` / `del` / `rmdir` users] → `del` / `rmdir` have F8 and Delete; `cd` is exactly what the box does better. README and Help say so. +- [Printable keys over the panels do nothing] → Matches the dedicated-focus choice; a later change can route them into type-ahead. Help's "Path box" topic and the README state Ctrl+G up front. +- [Completion reads a whole directory] → One read per folder per cycle on a worker thread, cached while the folder text is unchanged; a huge folder costs one enumeration, as listing it does. An unreachable share shows `Reading…` and never blocks input; a reply for abandoned text is dropped. +- [Snapshot churn] → The prompt row appears in 66 of the 138 `.snap` files; they regenerate with the change. +- [Ctrl+G availability] → A plain control code delivered by every terminal; rebindable anyway. +- [Case folding] → Prefix and target matching use `sort_key()` (Unicode lowercase); on case-sensitive filesystems two entries differing only by case both appear as candidates, and Enter lands on the exact match first (D5). + +## Open Questions + +None blocking. Follow-ups considered: start type-ahead from a plain printable key; a candidate popup; a small `Reading…` indicator in the box itself. diff --git a/openspec/changes/path-box/proposal.md b/openspec/changes/path-box/proposal.md new file mode 100644 index 0000000..d3e096c --- /dev/null +++ b/openspec/changes/path-box/proposal.md @@ -0,0 +1,50 @@ +# Change: path-box + +## Why + +The row above the F-key bar is a Norton Commander command line, but FileCommand never runs typed text through a shell: the buffer only recognises `cd`, `del` and `rmdir`, and everything else is an error. What remains is a typing sink that silently changes what Backspace, Up/Down, Left/Right, Enter and `+`/`-`/`*` do depending on whether it holds text, plus history, paste-name and clear-line machinery that exists only to serve those three verbs. Meanwhile the one thing the row is good for — getting to a path by typing it — has no completion, so a long path must be typed in full, and the panel can only *enter* a folder, never land on a file. + +This change replaces the command line with two **path boxes**, one under each panel, that show the panel's folder and complete folder and file names on Tab. Pressing Enter on a valid path puts the panel cursor on that entry. Everything else the command line did is removed. + +## What Changes + +- **The command-line row becomes two path boxes.** The row above the F-key bar splits at the panel divider. Each box shows its panel's current folder followed by a path separator (`C:\Github\FileCommand\`) and follows the panel through navigation, tab switches and panel swaps. Unfocused boxes are drawn in the existing `commandline` theme role; a long folder left-truncates with a leading `…`. +- **A box must be focused to type into it.** Ctrl+G (rebindable via `key.focus_path`) focuses the active panel's box; a mouse click on either box focuses that box and makes its panel active. While a box has focus it owns the keyboard and the terminal cursor and is drawn in the dialog text-field style. Esc, or a click outside the box, returns focus to the panel and resets the box to the panel's folder. While no box is focused the panels keep every key meaning they have today — Tab still switches panels, Backspace still goes to the parent, `+`/`-`/`*` always select — and plain printable keys over the panels are ignored. +- **Tab completes a path segment.** The text before the last separator names a folder; the text after it is a case-insensitive prefix. Tab replaces that last segment with the next child of the folder whose name starts with the prefix (folders first, then files, each sorted by name); Shift+Tab goes back; both wrap. A completed folder gets a trailing separator so the next Tab descends into it. The panel's mini-status shows `3/12 matches`, `no matches`, or `Reading…` while the folder is being read. Candidates come from a panel's own listing when the folder is that panel's current folder, otherwise from a directory read on a worker thread; a reply that no longer matches the box text is discarded. +- **Enter selects the entry.** The text resolves to an absolute path with the same rules the Copy/Move destination field uses (relative, `..`, drive-relative `D:x`, UNC). Its existence is answered from a panel listing when possible, otherwise by the existing off-thread path probe with a `Checking …` note. On success the panel navigates to the entry's parent folder if it is not already there and lands the cursor on the entry; a filesystem root, which has no parent, is entered instead; text naming the panel's own folder just closes the box. Focus returns to the panel. On failure the panel shows a not-found error and the box keeps focus and text. +- **BREAKING — removed:** the `cd`, `del` and `rmdir` builtins; command history (Up/Down recall, the Alt+F8 history dialog, Commands → History); Ctrl+Enter / Ctrl+] paste-name/paste-path; Ctrl+Y clear-line; the `key.paste_name`, `key.paste_path` and `key.clear_line` config keys (a config that still sets them gets the standard unrecognised-key startup warning). `history.json` keeps its format: the `commands` array is preserved on write but never appended to; directory history for Ctrl+J is unchanged. +- **Kept, re-homed:** Ctrl+O panels-on/off and the suspended-TUI shell run used by the file-action menu's Run entry and the F2 user menu (including the configurable shell) move from the `command-line` capability to `application-shell`, unchanged in behaviour. +- **Docs:** the F1 Help topic `Command line` becomes `Path box`; README key tables and the Command line section are rewritten. +- **Non-goals:** a candidate popup list; completion history; routing plain printable keys into the box or into type-ahead; any change to Ctrl+J fuzzy jump, Alt+letter type-ahead or the Ctrl+P quick filter. + +## Capabilities + +### New Capabilities + +- `path-box`: the two path boxes under the panels — their rendering, focus and editing, Tab completion, Enter selection, Esc, and the `key.focus_path` binding. + +### Modified Capabilities + +- `command-line`: **retired.** Every requirement is REMOVED; Ctrl+O and the suspended shell run are re-added under `application-shell`. The capability directory is deleted when the change is archived. +- `application-shell`: ADDED "Panels on/off reveals terminal scrollback" and "Configurable shell with documented latency tradeoff" (moved verbatim from `command-line`), plus "Shell commands run only from the Run entry and the user menu" and "A suspended shell run restores the terminal" (the former command-line "Run command via shell in suspended-TUI mode", split in two and reworded for its new home); MODIFIED "Terminal ownership and restoration on every exit", "Resize handling with 60x16 hard floor and placeholder", "Quit request keys and confirmation" (Esc in a focused path box cancels the box), "Bursts of key input render once", "The reducer performs no filesystem I/O", "Slow storage never blocks the event loop", "Modal overlays are mutually exclusive by construction" (the history dialog is gone). +- `panel-navigation`: MODIFIED "Parent-directory navigation" (Backspace is unconditional), "Swap panels (Ctrl+U)", "Superseded directory listings are discarded and cancelled" (scenario wording). +- `file-action-menu`: MODIFIED "Enter on a file opens the action menu" (no command-buffer precedence), "Menu actions route to existing flows" (wording). +- `text-field-editing`: MODIFIED "Caret model and editing keys", "Paste into a text field", "Terminal paste arrives as one paste" (the path box is the panels-level text field; no exceptions list). +- `type-ahead-jump`: MODIFIED "Entering type-ahead mode with Alt+letter", "Extending the pattern with printable keys"; RENAMED "Exiting type-ahead and restoring command-line routing" → "Exiting type-ahead", with its text MODIFIED (printables are ignored after exit). +- `responsive-layout`: MODIFIED "Degraded band and per-panel breakpoints" (wording), "Chrome degradation" (path-box scrolling and truncation). +- `quick-filter`: MODIFIED "Cursor and mini-status behavior under an active filter" (drops the paste bullet). +- `help-and-about`: MODIFIED "Help topic list" (`Path box`). +- `pulldown-menus`: MODIFIED "Menu contents" (Commands loses History). +- `startup-splash`: MODIFIED "Minimum hold and key dismissal" (wording). +- `user-menu`: MODIFIED "Navigate and dismiss the user menu" (wording). +- `theme-selection`: MODIFIED "Live theme preview while the picker is open" (wording). +- `filesystem-error-handling`: MODIFIED "Error messages name the affected path" (wording). + +## Impact + +- **Core (`crates/filecommand-core`):** remove `State.command_line` / `history` / `history_cursor`, the `CommandLine*`, `PasteCursor*`, `HistoryDialog*` commands and the panels-level `TextEdit` / `RequestPaste` arms, `ProbeIntent::{Cd, Delete}`, `run_command_line` and the builtin parsers in `update/panels.rs`, `HistoryDialogState`, the History menu item. Keep `resolve_cd_target` / `parse_drive_relative` (destination dialog), `panels_own_input` (viewer), `Role::CommandLine`, the `HistoryFile` reader/writer. Add `State.path_box: Option` (new `path_box.rs`), `Command::{PathBoxFocus, PathBoxEdit, PathBoxComplete, PathBoxAccept, PathBoxCancel, PathBoxPaste, CompletionsListed}`, `Effect::ListCompletions`, `ProbeIntent::Select`, `Keys.focus_path`; `pending_cursor_target` matching gains a case-insensitive fallback. +- **TUI (`crates/filecommand-tui`):** `views/command_line.rs` → `views/path_box.rs` (two boxes, reusing `draw_text_field` and `left_truncate_with_ellipsis`); `input/mod.rs` drops the `typing` gate and all command-line arms, adds a `map_path_box_key` routed ahead of the panel map; mouse: box click; `worker.rs` gains a completion-list reader; `app.rs` stops loading command history into state (the writer keeps the file's `commands`); hitmap `cmdline` → `path_boxes`; `views/history_dialog.rs` removed. +- **Tests:** every command-line test in `update/tests/{panels,file_op,listing,overlays,routing}.rs`, `input/tests.rs`, `config.rs`, `menu.rs`, the `app.rs` burst tests, and the `snapshot_views.rs` command-line and history-dialog snapshots; 66 of the 138 snapshot files regenerate because the prompt row changes; new reducer tests for the path box; new snapshots for unfocused and focused boxes and the matches mini-status. +- **Docs:** README (the "Command line" section and its "History" subsection, key tables, `key.*` list), F1 Help topic text. +- **Persisted formats:** `config.toml` loses three `key.*` keys and gains `key.focus_path`; `history.json` format unchanged. +- **Breaking** for users who typed `cd` / `del` / `rmdir`, used Alt+F8, or rebound the removed keys. diff --git a/openspec/changes/path-box/specs/application-shell/spec.md b/openspec/changes/path-box/specs/application-shell/spec.md new file mode 100644 index 0000000..175ba98 --- /dev/null +++ b/openspec/changes/path-box/specs/application-shell/spec.md @@ -0,0 +1,437 @@ +## ADDED Requirements + +### Requirement: Panels on/off reveals terminal scrollback + +When the user presses Ctrl+O, the system SHALL leave the alternate screen to expose the host terminal's scrollback containing prior command output, and any subsequent key press SHALL return to the alternate screen and redraw. The system SHALL NOT maintain its own command-output buffer; the visible output history is whatever the host terminal retains. + +#### Scenario: Ctrl+O leaves the alternate screen +- **WHEN** the user presses Ctrl+O +- **THEN** the app leaves the alternate screen so the terminal's scrollback (including prior command output) is visible + +#### Scenario: Any key returns to panels +- **WHEN** the terminal scrollback is showing after Ctrl+O and the user presses any key +- **THEN** the app re-enters the alternate screen and redraws the panels + +### Requirement: Shell commands run only from the Run entry and the user menu + +The system SHALL NOT run typed text through a shell. The suspended-TUI shell-spawn mechanism SHALL be reachable only from the file-action menu's Run entry and the F2 user menu, each via its own trigger. Enter on an executable target (PATHEXT match or `.lnk`) SHALL NOT spawn the target directly; it SHALL open the file-action menu for that entry, whose Run entry uses this spawn path. + +#### Scenario: Enter on an executable opens the menu instead of spawning +- **WHEN** the user presses Enter with the cursor on `setup.exe` +- **THEN** no child process spawns and the file-action menu opens for `setup.exe` +- **AND** activating the menu's Run entry spawns `setup.exe` via the suspended-TUI path + +#### Scenario: Typed text never reaches a shell +- **WHEN** a path box has focus, reads `C:\NORTON\dir` and the user presses Enter +- **THEN** no shell process is spawned; the text is treated as a path per `path-box` + +### Requirement: A suspended shell run restores the terminal + +Running a command through the suspended-TUI mechanism SHALL leave raw mode and the alternate screen, spawn the child inheriting stdio in the panel directory, wait for it to exit, prompt the user to press a key, then re-enter the alternate screen and raw mode and redraw. Terminal restore SHALL be idempotent so a failing or panicking child cannot leave the terminal in raw mode or the alternate screen. + +#### Scenario: Terminal restored after a failing child +- **WHEN** a child spawned via the Run entry or F2 user menu exits with an error or the spawn fails +- **THEN** the TUI is restored to the alternate screen and raw mode exactly once, and the app does not crash + +### Requirement: Configurable shell with documented latency tradeoff + +The system SHALL construct the shell invocation from a configurable shell setting, defaulting on Windows to `cmd.exe /C` for minimal spawn latency. The `config.toml` `shell =` key SHALL select an alternate shell such as PowerShell or `pwsh`, and the configuration SHALL document that PowerShell adds roughly 200 ms or more of spawn latency per command. The shell invocation SHALL be constructed as `shell + args + user text` in `filecommand-core` so it is unit-testable without a terminal. + +#### Scenario: Default shell is cmd.exe on Windows +- **WHEN** no `shell =` value is configured on Windows and a user-menu entry runs `dir` +- **THEN** the invocation is built from `cmd.exe /C` with the entry's text appended + +#### Scenario: Configured PowerShell shell is used +- **WHEN** `config.toml` sets `shell = "powershell"` and the user runs a user-menu entry +- **THEN** the invocation is built from the configured PowerShell executable rather than `cmd.exe` + +#### Scenario: Command construction is terminal-independent +- **WHEN** a unit test constructs a command line for the text `dir` with the default shell +- **THEN** the constructed invocation and working directory are produced by `filecommand-core` without requiring a terminal or an actual spawn + +## MODIFIED Requirements + +### Requirement: Terminal ownership and restoration on every exit + +The system SHALL acquire the alternate screen, raw mode, and (when enabled) mouse capture on startup and guarantee their release on every exit path — normal quit, error, and panic — via an RAII guard, so the user's terminal is never left in raw mode, on the alternate screen, or with mouse capture active after the process ends. When the TUI is suspended to run a shell command, an external editor, or the scrollback view, mouse capture SHALL be released before the alternate screen is left and re-acquired on resume. + +#### Scenario: Terminal acquired on startup + +- **WHEN** the application starts +- **THEN** it enters the alternate screen, enables raw mode, and enables mouse capture when configured + +#### Scenario: Terminal restored on normal exit + +- **WHEN** the application exits normally (for example via F10 quit) +- **THEN** mouse capture is disabled, raw mode is disabled, and the alternate screen is left before the process terminates + +#### Scenario: Terminal restored on error exit + +- **WHEN** the application exits because of an error after the terminal was acquired +- **THEN** the RAII guard still disables mouse capture and raw mode and leaves the alternate screen + +#### Scenario: Suspended shell run gets a normal terminal + +- **WHEN** the user runs a command from the file-action menu's Run entry or the F2 user menu +- **THEN** mouse capture is released before the child process runs and re-enabled after the TUI resumes + +### Requirement: Resize handling with 60x16 hard floor and placeholder + +The system SHALL reflow the UI on terminal resize events, laying out the interface at any size at or above the 60x16 hard floor, and MUST draw a `screen.placeholder` "terminal too small" message instead whenever the terminal is below that floor, using a single size check that governs both normal and splash states. The placeholder message SHALL name the floor: "resize to at least 60x16". Between the floor and the 80x24 nominal size the interface renders in the degraded forms defined by the `responsive-layout` capability; at or above 80x24 it renders at full fidelity. + +The placeholder SHALL be drawn over the active screen without discarding it. While the terminal is below the floor, the following SHALL persist unchanged and SHALL reappear when the terminal is enlarged to at or above the floor: + +- the panels and their tabs, cursor, selection, quick filter, and a focused path box with its text; +- the built-in editor: its buffer including unsaved edits, caret position, insert/overwrite mode, and modified state; +- the F3 viewer: its file, position, and text/hex and wrap modes; +- a file-operation setup dialog with its typed input; +- a running file-operation job, with its progress, overwrite-conflict, or error-recovery dialog; +- the end-of-job skipped-files summary; +- any open pull-down menu, dialog, or overlay. + +The startup splash is the only exception: it SHALL be dismissed when the terminal shrinks below the floor and SHALL NOT return when the terminal is enlarged. + +Worker results SHALL continue to be applied while the terminal is below the floor. These include directory-listing events, file-operation progress, conflict, error, and completion events, and replies to requests made before the shrink. A job that completes while the placeholder is shown therefore still re-reads the affected panels, and a conflict or error raised while it is shown waits for the user's answer. + +While the terminal is below the floor, input SHALL NOT reach the screen hidden under the placeholder: + +- Mouse input SHALL be ignored, except that while the quit-confirmation dialog is open over the placeholder its buttons SHALL stay clickable. +- F10 and Esc SHALL open the quit-confirmation dialog, which is drawn over the placeholder. The exception is when the built-in editor is the hidden screen: there F10 and Esc SHALL be ignored, so unsaved edits are never discarded without the editor's save-on-exit prompt. +- While the quit-confirmation dialog or the startup-warning dialog is open over the placeholder, that dialog's own keys SHALL work as usual. +- Every other key SHALL be ignored. + +While the terminal is below the floor, the screen under the placeholder does not own input, so these rules take precedence over that screen's own key handling, including Esc's cancel/dismiss meaning in a hidden menu or dialog under "Quit request keys and confirmation". + +#### Scenario: Reflow at or above the floor + +- **WHEN** the terminal is resized to a size at or above 60x16 +- **THEN** the UI reflows and lays out its regions to the new dimensions + +#### Scenario: Degraded band renders panels, not the placeholder + +- **WHEN** the terminal is 70x20 — below the 80x24 nominal size but at or above the floor +- **THEN** the panels, path boxes, and key bar render in their degraded forms and the placeholder is not shown + +#### Scenario: Placeholder below the floor + +- **WHEN** the terminal is below 60 columns or below 16 rows +- **THEN** the "terminal too small" placeholder message is drawn instead of the normal layout, naming the 60x16 floor + +#### Scenario: Shrinking below the floor during splash + +- **WHEN** the terminal shrinks below 60x16 while the startup splash is showing +- **THEN** the placeholder replaces the splash, and the splash does not return when the terminal is enlarged again + +#### Scenario: Recovery when resized back up + +- **WHEN** the terminal is enlarged from below the floor back to at or above 60x16 +- **THEN** the normal layout is drawn again in place of the placeholder + +#### Scenario: Unsaved editor buffer survives a shrink + +- **WHEN** the built-in editor has unsaved edits with the caret on line 12 in overwrite mode, and the terminal shrinks to 50x12 and is then enlarged to 80x24 +- **THEN** the editor is shown again with the same buffer contents, the caret on line 12, overwrite mode, and the modified indicator +- **AND** nothing was written to disk and no prompt was raised while the placeholder was shown + +#### Scenario: Viewer position and mode survive a shrink + +- **WHEN** the F3 viewer shows a file in hex mode scrolled to offset 0x4000, and the terminal shrinks below the floor and is then enlarged +- **THEN** the viewer is shown again on the same file, in hex mode, at offset 0x4000 + +#### Scenario: Viewer closes normally after a shrink + +- **WHEN** the terminal shrinks below the floor and is enlarged while the viewer is open, and the user then closes the viewer +- **THEN** the panels return and the viewer no longer holds the file open + +#### Scenario: Running job keeps reporting progress while undersized + +- **WHEN** a copy job is running with its progress dialog shown and the terminal shrinks below the floor +- **THEN** the placeholder is shown, the job keeps running, and its progress events keep updating the hidden progress dialog +- **AND** when the terminal is enlarged before the job ends, the progress dialog reappears showing the job's latest progress + +#### Scenario: Conflict raised while undersized waits for an answer + +- **WHEN** a running copy job reaches an overwrite conflict while the terminal is below the floor +- **THEN** the job waits for an answer and the conflict is not resolved by default +- **AND** when the terminal is enlarged the overwrite-conflict dialog is shown for that file, and answering it resumes the job + +#### Scenario: Job finishing while undersized still refreshes the panels + +- **WHEN** a copy job completes while the terminal is below the floor +- **THEN** the panels showing the source and destination directories re-read while the placeholder is still shown +- **AND** when the terminal is enlarged the panels show the copied files, or the end-of-job summary when items were skipped + +#### Scenario: Open dialog and typed text survive a shrink + +- **WHEN** the F5 destination dialog is open with `D:\backup` typed and the terminal shrinks below the floor and is then enlarged +- **THEN** the destination dialog is shown again containing `D:\backup` with the caret where it was + +#### Scenario: Typing over the hidden editor does nothing + +- **WHEN** the built-in editor is the hidden screen while the terminal is below the floor and the user types letters, presses Enter, F2, F10, or Esc +- **THEN** the buffer, caret, and file on disk are unchanged, no save runs, and no quit-confirmation or save-on-exit dialog opens + +#### Scenario: F10 over the placeholder asks to quit + +- **WHEN** the panels are the hidden screen while the terminal is below the floor and the user presses F10 +- **THEN** the quit-confirmation dialog opens over the placeholder +- **AND** cancelling it leaves the placeholder shown and the hidden panels unchanged + +#### Scenario: Mouse input is ignored while undersized + +- **WHEN** the terminal is below the floor, no quit-confirmation dialog is open, and the user clicks or turns the mouse wheel anywhere in it +- **THEN** no command reaches the hidden screen and nothing about it changes + +#### Scenario: Quit dialog buttons stay clickable over the placeholder + +- **WHEN** the quit-confirmation dialog is open over the placeholder and the user clicks its No button +- **THEN** the dialog closes, the placeholder stays shown, and the hidden screen is unchanged + +### Requirement: Quit request keys and confirmation + +The system SHALL open a quit-confirmation dialog — never exit directly — when the user requests quit via F10, Files → Quit, or Esc over the panels. Esc SHALL request quit whenever the panels own input (no pull-down menu and no modal dialog or overlay open), regardless of an active quick filter or type-ahead mode — while a path box has focus, Esc instead cancels the box (path-box "Esc cancels the path box"); inside menus, dialogs, the viewer, and editor sub-prompts Esc SHALL retain its existing cancel/dismiss meaning. Ctrl+C SHALL NOT request quit in any context (over the panels it is the clipboard Files action — see `clipboard-export`; in the built-in editor it remains Copy). Within the quit-confirmation dialog, Esc SHALL cancel alongside the existing confirm/cancel keys; Ctrl+C SHALL have no effect there. Cancelling SHALL restore the prior context exactly — anything open stays open, and quick-filter and type-ahead state survive. Confirming while a file operation is running SHALL abort the job through its existing cancel semantics before the application exits. + +#### Scenario: Esc over idle panels asks to quit + +- **WHEN** the panels are shown with no menu, dialog, or overlay open and the user presses Esc +- **THEN** the quit-confirmation dialog opens +- **AND** confirming exits the application and cancelling returns to the panels unchanged + +#### Scenario: Esc mid-composition asks to quit and cancel preserves the text + +- **WHEN** a path box has focus with edited text and the user presses Esc +- **THEN** the box closes and resets to the panel's directory and no quit-confirmation dialog opens +- **AND** a second Esc, now over the panels, opens the quit-confirmation dialog + +#### Scenario: Esc under an active quick filter asks to quit + +- **WHEN** a quick filter is active and the user presses Esc, then cancels the dialog +- **THEN** the quit-confirmation dialog opened instead of the filter being cleared +- **AND** after cancelling, the filter pattern and narrowed listing are unchanged + +#### Scenario: Esc still cancels menus and dialogs + +- **WHEN** a pull-down menu or a modal dialog is open and the user presses Esc +- **THEN** the menu or dialog closes as before and no quit-confirmation dialog opens + +#### Scenario: Ctrl+C never asks to quit + +- **WHEN** the user presses Ctrl+C over the panels, in the viewer, with a pull-down open, or with a modal dialog open +- **THEN** no quit-confirmation dialog opens (over the panels the clipboard Files action runs; elsewhere Ctrl+C neither quits nor triggers any unmodified-key action) + +#### Scenario: Ctrl+C in the built-in editor still copies + +- **WHEN** the built-in editor is open and the user presses Ctrl+C +- **THEN** the editor's Copy action runs and no quit-confirmation dialog opens + +#### Scenario: Esc backs out of the dialog; Ctrl+C does nothing there + +- **WHEN** the quit-confirmation dialog is open and the user presses Esc +- **THEN** the dialog closes and nothing else changes +- **WHEN** the quit-confirmation dialog is open and the user presses Ctrl+C +- **THEN** the dialog stays open and the application does not exit + +#### Scenario: Quit confirmed during a running file operation aborts the job first + +- **WHEN** a copy/move/delete job is running and the user requests quit via Esc, then confirms +- **THEN** the job is aborted through the same path as its cancel action before the application exits + +### Requirement: Bursts of key input render once + +When several input events are already queued, the system SHALL apply them in order — mapping each key against the state produced by the previous one — and SHALL draw once after the burst instead of once per key. A burst SHALL end after 64 events, after about 8 ms of processing, when no further input is queued, or when a mouse event is reached; the system SHALL then draw before handling further input, so the screen never falls far behind. Applying a burst SHALL produce the same final state as applying the same keys one at a time. + +#### Scenario: Holding PgDn renders one frame per burst + +- **WHEN** five PgDn key events are queued at once in a 100,000-entry directory +- **THEN** all five page movements are applied and the screen is drawn once, showing the final position + +#### Scenario: Routing follows the state each key produces + +- **WHEN** the queued keys are F7 followed by the characters `new` and Enter while the panels are focused +- **THEN** F7 opens the Make-directory dialog, `new` is typed into its field, and Enter confirms it, exactly as if the keys had been pressed slowly + +#### Scenario: A paste delivered as key events renders once per burst + +- **WHEN** the terminal delivers a 60-character pasted path as 60 key events while a path box has focus +- **THEN** all 60 characters are inserted in order and the screen is drawn at most once for every 64 of them + +### Requirement: The reducer performs no filesystem I/O + +`core::update` SHALL NOT perform filesystem I/O of any kind, directly or through any helper it calls. That includes metadata or existence queries, directory reads, and file opens. When handling a command depends on the current state of the filesystem, `core::update` SHALL: + +- answer from the state it already holds where that state suffices, such as an entry already listed in a panel; +- otherwise return an effect requesting the check, tagged with a fresh request id; +- complete the command only when the reply command carrying that id is applied. + +A reply whose id is no longer the outstanding request SHALL be discarded without changing state. This makes the "Pure core update function" rule checkable by a unit test that runs with no filesystem at all. + +#### Scenario: A command-line target outside the listing produces a probe request, not a disk read + +- **WHEN** a unit test applies Enter in a path box reading `C:\NORTON\sub\x.txt` to `core::update` with `C:\NORTON\sub` the current directory of neither panel +- **THEN** the returned effects contain a path-probe request for `C:\NORTON\sub\x.txt` tagged with a request id, and the panel's `cwd` is unchanged +- **AND** `core::update` made no filesystem call, so the result is the same whether or not `C:\NORTON\sub\x.txt` exists on the machine running the test + +#### Scenario: The probe reply completes the command + +- **WHEN** the path-probe reply for that request id arrives reporting that the entry exists +- **THEN** applying it through `core::update` navigates the panel to `C:\NORTON\sub` and lands the cursor on `x.txt` exactly as a synchronous check would have + +#### Scenario: A superseded probe reply is discarded + +- **WHEN** a second Enter is applied before the first probe's reply arrives, and the first reply then arrives +- **THEN** the first reply changes nothing: no navigation, no error message, and no dialog + +#### Scenario: A completion read is a request, not a disk read + +- **WHEN** a unit test applies Tab in a path box reading `D:\Proj\re` with `D:\Proj` the current directory of neither panel +- **THEN** the returned effects contain a completion-read request for `D:\Proj` tagged with a request id, and the box text is unchanged +- **AND** `core::update` made no filesystem call + +### Requirement: Slow storage never blocks the event loop + +These operations SHALL run on worker threads, never on the event-loop thread, so that a slow, removable, or unreachable volume cannot freeze input or redraw: + +- the path-box target check and the path-box completion read; +- opening a file in the F3 viewer; +- opening a file in the built-in editor; +- saving from the built-in editor; +- launching a file's associated application; +- writing the applied theme and the panel split to `config.toml`. + +While an editor or viewer open is outstanding, the status line of the panel it was requested from SHALL show `Opening …`, and the panels SHALL keep accepting input. A newer F3 or F4 request SHALL supersede an outstanding open. An open reply that arrives after the user has left the panels, or after a menu, dialog, or overlay has opened, SHALL be discarded, and a viewer reply's file handle SHALL be released. + +While a save is outstanding, the editor SHALL show a `Saving…` notice on the row directly above its F-key bar, leaving the header and F-key bar unchanged, and SHALL NOT accept edits, caret moves, or further save or quit commands until the save completes or fails. The screen SHALL keep redrawing meanwhile. + +Configuration writes SHALL be performed one at a time, in the order they were requested, by a single background writer, so that two rewrites of `config.toml` never interleave. A newer value for a setting SHALL replace an older value for the same setting that has not been written yet, so the value on disk after the writes complete is always the last one requested. Moving a write to the background writer SHALL NOT change how it is written: it SHALL remain the atomic write that theme-selection "Applied theme persists to configuration" and panel-split "Split persistence to configuration" require, made durable as configuration-files "Persisted configuration writes are durable" specifies where that requirement exists, and a failed write SHALL NOT interrupt the session. When the application exits, by quit or by any other exit path, the system SHALL first complete every configuration write still outstanding, waiting at most about 2 seconds. History persistence (`history.json`) is outside this rule. + +On Windows, the worker that launches the associated application SHALL initialize COM in a single-threaded apartment (`COINIT_APARTMENTTHREADED`) before calling the shell launch API and SHALL uninitialize it afterwards. A launch failure SHALL be reported on the status line of the panel the Open action was invoked on. + +#### Scenario: F4 on a slow share keeps the panels live + +- **WHEN** the user presses F4 on `notes.txt` in a panel showing `\\nas\share` and reading the file takes several seconds +- **THEN** the panels keep redrawing and moving the cursor in response to keys while the panel's status line shows `Opening notes.txt…` +- **AND** the built-in editor opens on `notes.txt` once the read completes + +#### Scenario: F3 on a file whose server stopped responding does not freeze the UI + +- **WHEN** the user presses F3 on a file on a share whose server has stopped responding +- **THEN** the UI keeps redrawing and accepting input while the open is outstanding +- **AND** when the open finally fails, the panel's status line shows the failure and the viewer does not open + +#### Scenario: Saving to a slow share shows progress and accepts no edits + +- **WHEN** the user presses F2 in the built-in editor on a file whose write takes several seconds +- **THEN** the editor shows `Saving…` above its F-key bar, the screen keeps redrawing, and typed characters are not inserted into the buffer +- **AND** when the write completes, `Saving…` disappears, the modified indicator clears, and typing edits the buffer again + +#### Scenario: A newer F3 supersedes an outstanding open + +- **WHEN** the user presses F3 on `slow.log`, moves to `fast.txt`, and presses F3 again before `slow.log` has opened +- **THEN** the viewer opens `fast.txt`, and the late answer for `slow.log` is discarded without replacing it + +#### Scenario: A late open does not pull the user out of a menu + +- **WHEN** an F4 open is outstanding and the user opens the F9 menu before it completes +- **THEN** the menu stays open, the editor does not open when the read completes, and the late result is discarded + +#### Scenario: Launching a slow handler leaves the panels interactive + +- **WHEN** the user activates Open on a document whose associated application takes several seconds to start +- **THEN** the panels keep redrawing and responding to keys during the launch +- **AND** if the launch fails, the failure is shown on the status line of the panel the Open action was invoked on, even if the user has switched panels since + +#### Scenario: COM is initialized on the launch worker + +- **WHEN** Open launches a file on Windows +- **THEN** the shell launch API is called on a worker thread that initialized COM in a single-threaded apartment beforehand, and that thread uninitializes COM after the call returns + +#### Scenario: Applying a theme on a slow profile directory keeps the UI live + +- **WHEN** the profile directory is on a network share that takes several seconds to answer and the user applies a theme from the theme picker +- **THEN** the new theme is shown and the UI keeps redrawing and accepting keys while `config.toml` is being rewritten in the background + +#### Scenario: Rapid split adjustments persist the final value + +- **WHEN** the split is at 50/50 on a 100-column terminal and the user presses Ctrl+Right eight times in quick succession, ending at a 66% split +- **THEN** the input is never held up by the writes, and once the writer has caught up `config.toml` contains `panel_split = 66` + +#### Scenario: A theme and a split change made together are both saved + +- **WHEN** the user applies a theme and, before that write finishes, adjusts the split +- **THEN** after both writes complete `config.toml` contains both the new `theme` and the new `panel_split` + +#### Scenario: A setting changed just before quit is saved + +- **WHEN** the user adjusts the split and immediately quits and confirms, on a responsive disk +- **THEN** the process exits only after `config.toml` holds the new `panel_split` value + +#### Scenario: An unresponsive profile directory cannot hold quit open + +- **WHEN** a configuration write is blocked on a profile directory that has stopped answering and the user confirms quit +- **THEN** the application exits after about 2 seconds, and `config.toml` is left with either its complete previous content or its complete new content + +### Requirement: Modal overlays are mutually exclusive by construction + +The system SHALL hold the state of the modal overlays in a single overlay slot in `filecommand-core`'s `State`, so that at most one of them is open at any time. The modal overlays are the F9 menu, the drive-select dialog, fuzzy jump, find file, the F2 user menu, the theme picker, the file-action menu, the Help window (with its nested About dialog), and the startup warning. Each overlay's handler SHALL receive that overlay's state directly from the slot, so no handler depends on a separately checked gate and none can run while its overlay is closed. + +The quit-confirmation dialog is not part of the slot. It SHALL remain a separate layer above every other context, as "Quit request keys and confirmation" requires. The file-operation dialogs, the viewer, and the editor SHALL remain phases. + +Which layer owns input SHALL be defined once, by a single precedence order in `filecommand-core`, and the reducer's command routing, the TUI's key routing, its mouse routing, and its rendering and hit-map construction SHALL all follow it. The order, highest first: +1. the quit confirmation; +2. a file-operation dialog; +3. the viewer; +4. the editor; +5. the open overlay; +6. the panels or the startup splash. + +A layer SHALL handle only the commands it claims; any other command, including worker replies, SHALL pass to the layers below it as it does today. While the terminal is below the 60x16 floor, input routing SHALL additionally follow "Resize handling with 60x16 hard floor and placeholder". + +This structure SHALL NOT change which overlay is shown, which keys or clicks it accepts, or how it renders, in any state the application can reach. + +#### Scenario: A menu item opens its dialog in place of the menu + +- **WHEN** the F9 menu is open and the user activates Commands → Find file +- **THEN** the menu closes and the find-file dialog opens +- **AND** only the find-file dialog is shown, and it receives the next key + +#### Scenario: The user menu's Themes entry replaces the user menu + +- **WHEN** the F2 user menu is open with its built-in Themes entry highlighted and the user presses Enter +- **THEN** the user menu closes and the theme picker opens in its place, with the active theme highlighted + +#### Scenario: Another overlay's shortcut is ignored while an overlay is open + +- **WHEN** the fuzzy-jump dialog is open and the user presses F9 or Alt+F1 +- **THEN** neither the menu nor the drive-select dialog opens +- **AND** the fuzzy-jump dialog stays open with its pattern and highlight unchanged + +#### Scenario: Unbound keys never reach the panels or the command line + +- **WHEN** the F2 user menu is open and the user types a printable character that the user menu does not bind +- **THEN** the key is ignored, so the path boxes and the panel cursor are unchanged + +#### Scenario: Late results of a dismissed search touch no other overlay + +- **WHEN** the user dismisses the find-file dialog with Esc while its search is still running, then opens the Help window +- **AND** further matches from the dismissed search arrive +- **THEN** the results are discarded, the Help window is unchanged, and no find-file dialog reappears + +#### Scenario: An overlay command with its overlay closed does nothing + +- **WHEN** a command that only an overlay binds (for example confirming fuzzy jump) reaches the reducer while that overlay is not open +- **THEN** the state is unchanged, no effect is returned, and nothing panics + +#### Scenario: The quit dialog stacks above an open overlay + +- **WHEN** the quit-confirmation dialog is open above an open F9 pull-down and the user cancels it +- **THEN** the pull-down is still open exactly as before, and it receives the next key + +#### Scenario: Worker replies pass beneath the quit dialog + +- **WHEN** a copy job is running, the quit-confirmation dialog is open above its progress dialog, and the job reports progress +- **THEN** the progress dialog underneath shows the new progress and the quit-confirmation dialog stays open + +#### Scenario: One precedence order everywhere + +- **WHEN** the routing of a key, the routing of a mouse event, and the drawing and hit map of a frame are each computed for the same state +- **THEN** all of them treat the same layer as the one that owns input, the layer defined by the single precedence order diff --git a/openspec/changes/path-box/specs/command-line/spec.md b/openspec/changes/path-box/specs/command-line/spec.md new file mode 100644 index 0000000..5a68444 --- /dev/null +++ b/openspec/changes/path-box/specs/command-line/spec.md @@ -0,0 +1,51 @@ +## REMOVED Requirements + +The `command-line` capability is retired by this change. Every requirement below is removed; archiving deletes `openspec/specs/command-line/`. Ctrl+O and the suspended-TUI shell run live on under `application-shell`. + +### Requirement: Command-line prompt and printable-key routing +**Reason**: The command line is replaced by two path boxes with dedicated focus; printable keys over the panels no longer have a sink. +**Migration**: `path-box` "Two path boxes replace the command line", "Focusing a path box", "Panel keys are independent of the path boxes". + +### Requirement: Run command via shell in suspended-TUI mode +**Reason**: Behaviour unchanged; it only moves home because the capability is retired. +**Migration**: `application-shell` "Shell commands run only from the Run entry and the user menu" and "A suspended shell run restores the terminal". + +### Requirement: Command history navigation +**Reason**: No command is ever run, so there is no command history. Up/Down always move the panel cursor. +**Migration**: None. `history.json` keeps its `commands` array for file compatibility; it is preserved on write and never appended to. + +### Requirement: Paste filename and path to command line +**Reason**: There is no command line to paste into. +**Migration**: None. Ctrl+Enter and Ctrl+] are unbound; `key.paste_name` and `key.paste_path` are no longer recognised in `config.toml`. Clipboard export of paths and names (`clipboard-export`) is unchanged. + +### Requirement: Panels on/off reveals terminal scrollback +**Reason**: Behaviour unchanged; it only moves home because the capability is retired. +**Migration**: `application-shell` "Panels on/off reveals terminal scrollback". + +### Requirement: Configurable shell with documented latency tradeoff +**Reason**: Behaviour unchanged; it only moves home because the capability is retired. +**Migration**: `application-shell` "Configurable shell with documented latency tradeoff". + +### Requirement: Command-line builtin whitelist +**Reason**: Nothing is typed to run. +**Migration**: None. + +### Requirement: cd navigates the active panel or rejects a nonexistent target +**Reason**: Replaced by the path box, which reaches any folder with completion and lands the cursor on the entry. +**Migration**: `path-box` "Enter resolves the path box text", "Enter selects the named entry", "Enter on the panel's own directory or a root", "A listed parent answers the existence check", "The existence check never blocks the event loop". + +### Requirement: del and rmdir route into the existing delete-confirmation flow +**Reason**: Typed delete verbs are removed. +**Migration**: F8, the Delete key and the file-action menu's Delete entry open the same delete-confirmation flow. + +### Requirement: Clear the command line with a dedicated key +**Reason**: There is no command line to clear; Esc resets a focused path box. +**Migration**: None. Ctrl+Y is unbound; `key.clear_line` is no longer recognised in `config.toml`. + +### Requirement: Command history dialog (Alt+F8) +**Reason**: There is no command history. +**Migration**: None. Alt+F8 is unbound and Commands → History is removed (`pulldown-menus` "Menu contents"). + +### Requirement: cd, del and rmdir targets resolve to a normalized absolute path +**Reason**: The verbs are removed; the resolution rules themselves are owned by `operation-dialogs`. +**Migration**: `path-box` "Enter resolves the path box text" resolves box text by operation-dialogs "Relative destinations resolve against the source panel directory". diff --git a/openspec/changes/path-box/specs/file-action-menu/spec.md b/openspec/changes/path-box/specs/file-action-menu/spec.md new file mode 100644 index 0000000..d52d657 --- /dev/null +++ b/openspec/changes/path-box/specs/file-action-menu/spec.md @@ -0,0 +1,62 @@ +## MODIFIED Requirements + +### Requirement: Enter on a file opens the action menu + +When the user presses Enter with the active panel's cursor on a file entry, the system SHALL open a modal file-action menu targeting that entry. The menu SHALL target the cursor entry only — it SHALL NOT consume or alter the multi-entry selection. Enter on a directory or the `..` entry SHALL continue to navigate and is not affected by this menu. Menu state SHALL live in `filecommand-core` with all mutations flowing through `core::update`. + +#### Scenario: Enter on a non-executable file opens the menu + +- **WHEN** the cursor is on `notes.txt` and the user presses Enter +- **THEN** the file-action menu opens as a modal dialog targeting `notes.txt` + +#### Scenario: Selection is not consumed + +- **WHEN** 5 entries are selected, the cursor is on `report.txt`, and the user presses Enter +- **THEN** the menu opens targeting only `report.txt` +- **AND** the 5-entry selection remains intact while the menu is open and after it closes + +#### Scenario: Non-empty command buffer takes precedence + +- **WHEN** a path box has focus, reads `C:\NORTON\dir`, the panel cursor is on a file and the user presses Enter +- **THEN** Enter is handled by the path box (path-box "Enter selects the named entry") and no file-action menu opens + +#### Scenario: Enter on a directory still navigates + +- **WHEN** the cursor is on a subdirectory and the user presses Enter +- **THEN** the panel navigates into that directory and no file-action menu opens + +### Requirement: Menu actions route to existing flows + +Each file-action menu entry SHALL route into the existing capability flow for that action, applied to the menu's target entry: Open SHALL launch the target through the platform file association per "Open with the associated application"; View SHALL open the F3 viewer; Edit SHALL follow the F4 edit path (external editor when configured, otherwise the built-in editor rules); Copy SHALL open the F5 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Move SHALL open the F6 destination-input dialog pre-filled with the opposite panel's path, scoped to the single target entry; Delete SHALL open the F8 delete-confirmation flow for the single target entry; Send to clipboard SHALL run the `clipboard-export` Files action scoped to the single target entry; Run (executables only) SHALL use the existing suspended-TUI spawn path. Downstream behavior — overwrite conflicts, progress, error recovery, skipped-files summary, and panel re-read on completion — SHALL follow the existing `file-operations` and `operation-dialogs` requirements unchanged. + +#### Scenario: Open launches the associated application + +- **WHEN** the user activates Open for `report.pdf` +- **THEN** the menu closes, `report.pdf` is handed to the platform file association, and the panels remain interactive without any TUI suspension + +#### Scenario: View opens the viewer + +- **WHEN** the user activates View for `notes.txt` +- **THEN** the menu closes and the F3 viewer opens on `notes.txt` + +#### Scenario: Copy opens the destination dialog for the single entry + +- **WHEN** the left panel is active, the right panel shows `D:\backup`, and the user activates Copy for `report.txt` +- **THEN** the menu closes and the destination-input dialog opens pre-filled with `D:\backup` +- **AND** accepting it starts a copy job whose scope is exactly `report.txt` + +#### Scenario: Delete requires the existing confirmation + +- **WHEN** the user activates Delete for `notes.txt` +- **THEN** the menu closes and the delete-confirmation dialog names `notes.txt` and states that deletion is permanent +- **AND** declining the confirmation deletes nothing + +#### Scenario: Send to clipboard copies the single entry + +- **WHEN** the user activates Send to clipboard for `notes.txt` +- **THEN** the menu closes and the clipboard holds a file object for exactly `notes.txt` + +#### Scenario: Run spawns via the suspended-TUI path + +- **WHEN** the user activates Run for `build.bat` +- **THEN** the menu closes and `build.bat` runs via the suspended-TUI spawn path in the panel's current directory diff --git a/openspec/changes/path-box/specs/filesystem-error-handling/spec.md b/openspec/changes/path-box/specs/filesystem-error-handling/spec.md new file mode 100644 index 0000000..b004da3 --- /dev/null +++ b/openspec/changes/path-box/specs/filesystem-error-handling/spec.md @@ -0,0 +1,43 @@ +## MODIFIED Requirements + +### Requirement: Error messages name the affected path + +A user-facing filesystem error SHALL identify the file or directory it concerns together with the reason for the failure. A bare operating-system message, such as "Access is denied. (os error 5)", SHALL NOT be shown on its own. + +The status-line messages that are built from an operating-system error SHALL name the item by its file or directory name; the panel's title already shows the directory it lives in. They SHALL take these forms: + +- a panel whose directory cannot be read SHALL show `Cannot read : ` in its read-error state; +- a failure to open a file in the F3 viewer or the built-in editor SHALL read `Cannot open : `; +- a failed built-in editor save SHALL read `Cannot save : `, where the reason is the operating system's message or the editor's own explanation of why it refused to save. + +Status-line messages that already name their target, such as the path box's not-found rejection and the Open-with-associated-application failure, keep their existing text. + +The multi-line job dialogs SHALL show the full path: + +- the error-recovery dialog SHALL show the full path of the failing item above the operating-system message; +- each row of the end-of-job summary SHALL show the item's full path followed by its reason. + +#### Scenario: Access-denied listing names the directory + +- **WHEN** a panel navigates to `C:\Windows\CSC` and the read fails with access denied +- **THEN** the panel's read-error message reads `Cannot read CSC: Access is denied. (os error 5)`, and the panel title shows `C:\Windows\CSC` + +#### Scenario: Viewer open failure names the file + +- **WHEN** F3 is pressed on `locked.db` and the file cannot be opened because another process holds it exclusively +- **THEN** the panel's status line reads `Cannot open locked.db: ` followed by the operating system's sharing-violation message + +#### Scenario: Editor save failure names the file + +- **WHEN** F2 in the built-in editor fails to write `notes.txt` because the disk is full +- **THEN** the editor reports `Cannot save notes.txt: ` followed by the operating system's message, and the buffer stays modified + +#### Scenario: The error-recovery dialog shows the full path + +- **WHEN** a copy job fails to read `D:\data\reports\q3.xlsx` with a permission error +- **THEN** the error-recovery dialog shows `D:\data\reports\q3.xlsx` on one line and the operating system's message on the next + +#### Scenario: Summary rows show the full path and reason + +- **WHEN** a job ends having skipped `D:\data\old.log` because the target already exists +- **THEN** the end-of-job summary row reads `D:\data\old.log: target already exists` diff --git a/openspec/changes/path-box/specs/help-and-about/spec.md b/openspec/changes/path-box/specs/help-and-about/spec.md new file mode 100644 index 0000000..31164b2 --- /dev/null +++ b/openspec/changes/path-box/specs/help-and-about/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Help topic list + +The system SHALL render, below the identity header, a scrollable list of help topics whose first entry is `About FileCommand`, highlighted (white on black) as the initial cursor position, followed by the v1 topics `Keyboard reference`, `Mouse`, `Panels and display modes`, `File operations`, `Menus`, `Viewer`, `Editor`, `Path box`, `Modern extras`, and `Configuration`, and it SHALL show `↑` / `↓` scroll arrows on the right border only when the list overflows the visible area. + +#### Scenario: List opens with About FileCommand highlighted first + +- **WHEN** the Help window first appears +- **THEN** the topic list is shown with `About FileCommand` as the first entry and the cursor highlight (white on black) resting on it + +#### Scenario: Cursor moves through the topic list + +- **WHEN** the user presses Down arrow one or more times within the topic list +- **THEN** the white-on-black highlight moves to the next topic and the list scrolls as needed to keep the highlighted topic visible + +#### Scenario: Scroll arrows appear only on overflow + +- **WHEN** the topic list is taller than the visible list area +- **THEN** `↑` / `↓` scroll arrows render on the window's right border, and they are absent when the entire list fits + +#### Scenario: Mouse topic + +- **WHEN** the user opens the `Mouse` topic +- **THEN** its page documents click, double-click, wheel, right-click, Ctrl+click, Shift+drag for native text selection, and the `[mouse]` / `--nomouse` off switches + +#### Scenario: Path box topic + +- **WHEN** the user opens the `Path box` topic +- **THEN** its page documents the focus key (Ctrl+G, `key.focus_path`), Tab / Shift+Tab completion, Enter selection, Esc, and that no command line or shell builtins exist diff --git a/openspec/changes/path-box/specs/panel-navigation/spec.md b/openspec/changes/path-box/specs/panel-navigation/spec.md new file mode 100644 index 0000000..5f37d69 --- /dev/null +++ b/openspec/changes/path-box/specs/panel-navigation/spec.md @@ -0,0 +1,75 @@ +## MODIFIED Requirements + +### Requirement: Parent-directory navigation + +The system SHALL navigate the active panel to its parent directory when Ctrl+PgUp is pressed, and when Backspace is pressed while no path box has focus. When the current directory is already a filesystem root with no parent, the navigation SHALL be a no-op. + +#### Scenario: Ctrl+PgUp goes to the parent + +- **WHEN** the active panel is in a non-root directory and Ctrl+PgUp is pressed +- **THEN** the panel SHALL navigate to its parent directory and begin listing it + +#### Scenario: Backspace on empty command line goes to the parent + +- **WHEN** no path box has focus and Backspace is pressed +- **THEN** the active panel SHALL navigate to its parent directory + +#### Scenario: Parent navigation at a root is a no-op + +- **WHEN** the active panel is at a filesystem root with no parent and a parent-navigation command is issued +- **THEN** the panel SHALL remain in the current directory unchanged + +### Requirement: Swap panels (Ctrl+U) + +The system SHALL exchange the left and right panels' complete state — directory, tabs, listing, cursor, scroll offset, selection, sort mode, filter, display mode, and git info — on Ctrl+U or Commands → Swap panels, keeping the active side and the split percentage unchanged, without re-reading either directory. A swap requested while either panel's listing is still streaming SHALL be ignored. After a swap the system SHALL re-issue each panel's pending Info-mode and git-info queries so no reply from before the swap is applied to the wrong panel. + +#### Scenario: Swap keeps focus on the same side +- **WHEN** the left panel is active showing `C:\a` and the right shows `D:\b`, and the user presses Ctrl+U +- **THEN** the left panel shows `D:\b`, the right shows `C:\a`, the left panel is still active, and the left path box reads `D:\b\` while the right reads `C:\a\` + +#### Scenario: Swap carries tabs and selection +- **WHEN** the right panel has three tabs and two selected entries and the user presses Ctrl+U +- **THEN** the left panel now has those three tabs and the same two selected entries + +#### Scenario: Swap refused while reading +- **WHEN** the right panel's listing is still streaming and the user presses Ctrl+U +- **THEN** nothing changes + +#### Scenario: Git info follows the swap +- **WHEN** the left panel is inside a git repository and the user presses Ctrl+U +- **THEN** the right panel's border shows that repository's branch suffix after its re-issued query resolves, and the left panel shows none + +### Requirement: Superseded directory listings are discarded and cancelled + +Every directory listing the system starts for a panel SHALL be tagged with a fresh request id that becomes that panel's current listing request, and every streamed batch of entries, completion notice, and failure notice SHALL carry the id of the listing that produced it. The system SHALL apply a listing result only when its id matches the panel's current listing request and SHALL silently discard every other listing result, so that a listing superseded by a navigation, a re-read, a drive change, a file-operation refresh, or any other new listing of the same panel never adds entries to, completes, or fails the listing that replaced it. When a listing is superseded while it is still being read, the system SHALL signal its reader to stop; the reader SHALL stop at its next check and SHALL send no further entries, completion notice, or failure notice. + +#### Scenario: Backspace out of a slow directory mid-listing + +- **WHEN** the active panel enters `C:\big` and, while that listing is still streaming, the user presses Backspace to return to `C:\` +- **THEN** the panel shows only the entries of `C:\` — no entry from `C:\big`, not even `C:\big`'s `..` entry — and the `Reading… N` count covers only `C:\`'s entries +- **AND** the read of `C:\big` is signalled to stop + +#### Scenario: cd elsewhere mid-listing + +- **WHEN** the panel's listing of `D:\logs` is still streaming and the user focuses the path box, types `C:\Work\notes.txt` (an existing file) and presses Enter, so the panel navigates to `C:\Work` +- **THEN** only `C:\Work`'s entries appear in the panel, and none of `D:\logs`'s entries arrive later + +#### Scenario: Superseded completion does not complete the new listing + +- **WHEN** a superseded listing's completion notice arrives after the panel has started reading a different directory +- **THEN** the mini-status keeps showing `Reading… N` for the new listing until the new listing's own completion arrives + +#### Scenario: Superseded failure does not mark the new listing failed + +- **WHEN** the read of a directory the user has already left fails with access denied after the panel started reading another directory +- **THEN** no error is shown for the current directory and its listing continues normally + +#### Scenario: Two quick re-reads do not duplicate entries + +- **WHEN** the user presses Ctrl+R twice in quick succession on a directory with 500 entries +- **THEN** after both reads finish the panel lists each of the 500 entries exactly once + +#### Scenario: Superseded reader stops + +- **WHEN** a listing is superseded while its reader is still producing entries +- **THEN** the reader stops at its next check and sends no further entries, completion notice, or failure notice diff --git a/openspec/changes/path-box/specs/path-box/spec.md b/openspec/changes/path-box/specs/path-box/spec.md new file mode 100644 index 0000000..8284c4c --- /dev/null +++ b/openspec/changes/path-box/specs/path-box/spec.md @@ -0,0 +1,265 @@ +# Spec Delta + +## Purpose + +Two path boxes under the panels show each panel's current directory, complete folder and file names on Tab, and on Enter put the panel cursor on the entry the text names. + +## ADDED Requirements + +### Requirement: Two path boxes replace the command line + +The system SHALL render, on the row directly above the F-key bar, one path box under each panel, occupying that panel's columns. An unfocused box SHALL show its panel's current directory followed by the platform path separator in the `commandline` theme role, SHALL follow the panel through navigation, tab switches and swaps, and SHALL truncate from the left with a leading `…` when wider than the box. The row SHALL show no shell prompt, and typed text SHALL never be run through a shell. + +#### Scenario: Both boxes show their panel's directory +- **WHEN** the left panel is at `C:\NORTON` and the right panel is at `D:\WORK` +- **THEN** the row above the key bar shows `C:\NORTON\` under the left panel and `D:\WORK\` under the right panel + +#### Scenario: A box follows its panel +- **WHEN** the left panel navigates into `C:\NORTON\sub` +- **THEN** the left box reads `C:\NORTON\sub\` and the right box is unchanged + +#### Scenario: Switching panels changes no box text +- **WHEN** the user presses Tab to make the other panel active +- **THEN** both boxes keep showing their own panel's directory + +#### Scenario: A long directory truncates from the left +- **WHEN** a panel's directory is wider than its box +- **THEN** the box shows a leading `…` followed by the tail of the path, and nothing paints outside the box + +### Requirement: Focusing a path box + +The system SHALL give keyboard focus to the active panel's path box on the focus-path chord (default Ctrl+G, overridable via `key.focus_path`) while the panels own input, and to either box when it is clicked; a click SHALL also make that box's panel active. Focusing SHALL end an active type-ahead search. On focus the box SHALL hold the panel's directory plus separator with the caret at the end, own the terminal cursor, and render in the dialog text-field style. + +#### Scenario: Ctrl+G focuses the active panel's box +- **WHEN** the right panel is active at `D:\WORK` and the user presses Ctrl+G +- **THEN** the right box has focus, reads `D:\WORK\` with the caret at the end, and the terminal cursor is in it + +#### Scenario: Clicking the other box activates that panel +- **WHEN** the left panel is active and the user clicks the right path box +- **THEN** the right panel becomes active and the right box has focus + +#### Scenario: Focusing ends type-ahead +- **WHEN** type-ahead is active with pattern `re` and the user presses Ctrl+G +- **THEN** type-ahead ends, its pattern is gone from the mini-status, and the box has focus + +#### Scenario: Rebinding the focus key +- **WHEN** `config.toml` sets `key.focus_path = "ctrl+e"` +- **THEN** Ctrl+E focuses the box and Ctrl+G no longer does + +### Requirement: A focused path box owns the keyboard + +While a box has focus it SHALL own every key: keys it does not bind SHALL be ignored and SHALL NOT reach the panels, menus or F-key actions. The box SHALL lose focus and reset to the panel's directory when a menu, dialog or overlay opens for any other reason, or when the user clicks outside it; the click is then handled as it would be with no box focused. + +#### Scenario: Unbound keys stay inside the box +- **WHEN** a box has focus and the user presses F9, then Alt+F1, then Ctrl+U +- **THEN** no menu or dialog opens, the panels are not swapped, and the box keeps focus and text + +#### Scenario: A click outside the box cancels it +- **WHEN** a box has focus with edited text and the user clicks an entry in the left panel +- **THEN** the box closes showing the panel's directory and the clicked entry receives the panel cursor + +#### Scenario: A dialog opening for another reason closes the box +- **WHEN** a box has focus and the startup-warning dialog or a file-operation dialog opens +- **THEN** the box closes and resets, and the dialog receives the next key + +### Requirement: Panel keys are independent of the path boxes + +While no box has focus the panels SHALL keep their full key map regardless of any box text: Tab switches panels, Backspace goes to the parent, Up/Down move the cursor, Left/Right are unbound, `+`/`-`/`*` open group selection and invert selection, and a plain printable key over the panels SHALL be ignored. + +#### Scenario: Panel keys keep their meaning without focus +- **WHEN** no box has focus and the user presses Tab, then Backspace, then `+` +- **THEN** the active panel switches, the newly active panel goes to its parent, and the group-select dialog opens; no box text changes + +#### Scenario: A printable key over the panels is ignored +- **WHEN** no box has focus and the user types `d` +- **THEN** nothing changes: no box text, no cursor movement, no search + +#### Scenario: Up and Down always move the cursor +- **WHEN** no box has focus and the user presses Up +- **THEN** the panel cursor moves up one row + +### Requirement: Editing inside a focused path box + +A focused path box SHALL be backed by the shared line-edit model (text-field-editing "Caret model and editing keys") and SHALL bind its full key set — printable insert, Backspace, Delete, Left/Right, Home/End, Ctrl+Left/Ctrl+Right, Ctrl+Backspace/Ctrl+Delete — plus Ctrl+V and Shift+Ins paste (text-field-editing "Paste into a text field") and a terminal paste (text-field-editing "Terminal paste arrives as one paste"). The text SHALL scroll horizontally so the caret stays visible. + +#### Scenario: Edit mid-path +- **WHEN** the box reads `C:\NORTON\` and the user presses Home, Delete three times and types `D:\` +- **THEN** the box reads `D:\NORTON\` with the caret after the first `\` + +#### Scenario: Paste into the box +- **WHEN** the box has focus with the caret at the end of `C:\` and the clipboard holds `Program Files` +- **THEN** after Ctrl+V the box reads `C:\Program Files` + +#### Scenario: Long text keeps the caret visible +- **WHEN** the box text is wider than the box and the caret is at the end +- **THEN** the visible window scrolls so the caret remains on-screen + +### Requirement: Tab completes the last path segment + +While a box has focus, Tab SHALL complete the segment after the last path separator: the text before it names a folder, resolved against the panel directory as in "Enter resolves the path box text", and the text after it is a case-insensitive prefix. Tab SHALL replace that segment with a candidate's on-disk name, and a directory candidate SHALL get a trailing separator. + +#### Scenario: Tab completes from the panel listing +- **WHEN** the active panel at `C:\NORTON` lists `docs`, `Downloads` and `data.txt` among others, the box reads `C:\NORTON\d` and the user presses Tab +- **THEN** the box reads `C:\NORTON\docs\` (directories come first) and no completion-read request is emitted + +#### Scenario: The on-disk name replaces the typed prefix +- **WHEN** the box reads `C:\NORTON\READ` and the folder holds `readme.md` +- **THEN** Tab makes the box read `C:\NORTON\readme.md` + +#### Scenario: Relative and parent-relative folders complete +- **WHEN** the active panel is at `C:\work\proj`, the box reads `..\x` and `C:\work\xray` exists +- **THEN** Tab makes the box read `..\xray\` + +### Requirement: Completion candidates and cycling order + +The candidates SHALL be every child of the folder whose name starts with the prefix, directories first then files, each in case-insensitive name order. Tab SHALL move to the next candidate and Shift+Tab to the previous one; both SHALL wrap. + +#### Scenario: Tab cycles and wraps, Shift+Tab reverses +- **WHEN** the candidates are `docs`, `Downloads` and `data.txt`, the box reads `C:\NORTON\docs\` after a first Tab, and the user presses Tab, Tab, Tab, then Shift+Tab +- **THEN** the text goes `C:\NORTON\Downloads\`, `C:\NORTON\data.txt`, `C:\NORTON\docs\`, then `C:\NORTON\data.txt` + +#### Scenario: Matching ignores case +- **WHEN** the folder holds `Readme.md` and `recipes` and the box reads `C:\NORTON\RE` +- **THEN** Tab offers `recipes\` first (a directory) and then `Readme.md` + +### Requirement: Completion cycle and mini-status + +The prefix in force SHALL be the one present when the cycle started, so repeated Tab presses cycle rather than narrow; any edit other than Tab or Shift+Tab SHALL end the cycle. While a cycle is active the box's panel mini-status SHALL show `k/n matches`; when no child matches it SHALL show `no matches` and Tab SHALL leave the text unchanged. + +#### Scenario: Mini-status follows the cycle +- **WHEN** three children match and the user presses Tab three times +- **THEN** the mini-status shows `1/3 matches`, `2/3 matches`, then `3/3 matches` + +#### Scenario: An edit ends the cycle +- **WHEN** a cycle is active on `C:\NORTON\docs\` and the user presses Backspace +- **THEN** the mini-status no longer shows a match count, and the next Tab starts a new cycle for the new prefix + +#### Scenario: No match +- **WHEN** the box reads `C:\NORTON\zz` and no child of `C:\NORTON` starts with `zz` +- **THEN** Tab leaves the text unchanged and the mini-status shows `no matches` + +### Requirement: Completion candidates come from a panel listing when possible + +Children SHALL be obtained without filesystem I/O in the reducer: when the folder is the current directory of a panel whose listing is complete, that listing SHALL answer at once and no completion read SHALL be requested. + +#### Scenario: The other panel's listing answers +- **WHEN** the left box reads `D:\WORK\re`, the right panel shows `D:\WORK` with a complete listing, and the user presses Tab +- **THEN** the candidates come from the right panel's listing and no completion-read request is emitted + +#### Scenario: A streaming listing is not used +- **WHEN** the folder is a panel's current directory but that listing is still streaming +- **THEN** Tab requests a completion read rather than answering from the partial listing + +### Requirement: Completion reads run off the event loop + +When no complete panel listing covers the folder, the reducer SHALL emit a completion-read request tagged with a request id, run on a worker thread through the `\\?\` long-path abstraction, with the mini-status showing `Reading…` until the reply arrives; Tab meanwhile SHALL be ignored. A reply SHALL be applied only when its id is outstanding and its folder is the one the text names. Names SHALL be reused while the folder part of the text is unchanged. + +#### Scenario: A folder outside the panels is read on a worker +- **WHEN** the box reads `D:\Proj\re` and `D:\Proj` is the current directory of neither panel +- **THEN** Tab emits a completion-read request for `D:\Proj` with a request id, the mini-status shows `Reading…`, and the box text is unchanged +- **AND** when the reply lists `README.md`, `release` and `src`, the box reads `D:\Proj\release\` and the mini-status shows `1/2 matches` + +#### Scenario: A stale reply is discarded +- **WHEN** a completion read for `D:\Proj` is outstanding and the user edits the box to `E:\x` before it answers +- **THEN** the reply changes nothing and the mini-status does not show matches for `D:\Proj` + +#### Scenario: Cached names survive edits within the same folder +- **WHEN** the reply for `D:\Proj` has been applied and the user presses Backspace until the box reads `D:\Proj\s`, then Tab +- **THEN** the box reads `D:\Proj\src\` without a second completion-read request + +#### Scenario: Unreadable folder +- **WHEN** the completion read for `\\offline\share` fails +- **THEN** the mini-status shows `no matches`, Tab leaves the text unchanged, and the UI never stopped accepting keys + +### Requirement: Enter resolves the path box text + +Enter in a focused box SHALL resolve the text, ignoring one trailing separator, to a normalized absolute path by the rules of operation-dialogs "Relative destinations resolve against the source panel directory", against the box's panel directory, and SHALL verify that an entry of that name exists in its parent. When no such entry exists, or the parent cannot be read, the panel's error state SHALL name the path and the box SHALL keep focus and text. + +#### Scenario: A relative path resolves against the panel +- **WHEN** the active panel is at `C:\work\proj`, `C:\work\old.txt` exists and the box reads `..\old.txt` +- **THEN** the panel lists `C:\work` with the cursor on `old.txt` + +#### Scenario: A drive-relative path resolves like a destination +- **WHEN** the active panel is at `D:\work`, `D:\work\old` exists and the box reads `D:old` +- **THEN** the panel stays at `D:\work` with the cursor on `old` + +#### Scenario: A missing path keeps the box open +- **WHEN** the box reads `C:\NORTON\nosuch.txt` and no such entry exists +- **THEN** the panel's directory is unchanged, the panel shows an error naming `C:\NORTON\nosuch.txt`, and the box keeps focus with its text + +### Requirement: Enter selects the named entry + +When the entry exists, the box's panel SHALL navigate to the entry's parent directory unless it is already there, SHALL place the cursor on the entry (matching the exact name first, else case-insensitively), and the box SHALL close with focus returning to the panel. The panel SHALL NOT enter a directory the text names; a directory is selected in its parent. + +#### Scenario: Enter on a listed file lands the cursor +- **WHEN** the active panel is at `C:\NORTON`, lists `readme.md`, the box reads `C:\NORTON\readme.md` and the user presses Enter +- **THEN** the box closes, the panel is still at `C:\NORTON`, and the cursor is on `readme.md` + +#### Scenario: Enter on a folder selects it in its parent +- **WHEN** the active panel is at `C:\NORTON`, the box reads `C:\NORTON\docs\` and the user presses Enter +- **THEN** the panel stays at `C:\NORTON` with the cursor on `docs`; it does not enter `docs` + +#### Scenario: Enter on a path elsewhere navigates to the parent and lands +- **WHEN** the active panel is at `C:\NORTON`, the box reads `D:\Proj\src\main.rs`, which exists, and the user presses Enter +- **THEN** once the check succeeds the panel lists `D:\Proj\src` with the cursor on `main.rs` and the box is closed + +#### Scenario: Case-insensitive landing +- **WHEN** the box reads `C:\NORTON\README.MD` and the listing holds `readme.md` +- **THEN** the cursor lands on `readme.md` + +### Requirement: Enter on the panel's own directory or a root + +If the resolved path equals the box's panel current directory, Enter SHALL close the box with no other change. If the resolved path is a filesystem root with no parent, the panel SHALL navigate into it and the box SHALL close. + +#### Scenario: Enter on the panel's own directory just closes the box +- **WHEN** the box reads `C:\NORTON\` unchanged since focus and the user presses Enter +- **THEN** the box closes, and the panel's directory and cursor are unchanged + +#### Scenario: A drive root is entered +- **WHEN** the box reads `D:\` and `D:` exists +- **THEN** the panel navigates to `D:\` and the box closes + +#### Scenario: A UNC share root is entered +- **WHEN** the box reads `\\server\share\` and the share exists +- **THEN** the panel navigates to `\\server\share` and the box closes + +### Requirement: A listed parent answers the existence check + +When the parent is the current directory of a panel whose listing is complete, that listing SHALL answer the existence check, matching the name case-insensitively, with no filesystem I/O in the reducer. + +#### Scenario: A listed entry needs no filesystem check +- **WHEN** the active panel at `C:\NORTON` lists `sub` and the box reads `C:\NORTON\sub` when Enter is pressed +- **THEN** the cursor lands on `sub` in response to the Enter itself, without any filesystem check + +#### Scenario: The other panel's listing answers the check +- **WHEN** the left box reads `D:\WORK\notes.txt`, the right panel shows `D:\WORK` with `notes.txt` listed, and the user presses Enter +- **THEN** the left panel navigates to `D:\WORK` with the cursor on `notes.txt` and no path-probe request is emitted + +### Requirement: The existence check never blocks the event loop + +When no complete panel listing covers the parent, the check SHALL run on a worker thread through the `\\?\` long-path abstraction, tagged with a request id, while the box's panel status line shows `Checking …`; the reply SHALL be applied only if it is the most recent check and the box still has focus with the same text, and SHALL otherwise be discarded. The panel's directory SHALL NOT change until the check succeeds. + +#### Scenario: An unlisted target is checked off-thread +- **WHEN** the box reads `\\slow\share\x.txt`, which is listed in neither panel, and the user presses Enter +- **THEN** the UI keeps redrawing and accepting keys while the panel's status line shows `Checking \\slow\share\x.txt…`, and the panel's directory is unchanged +- **AND** when the check succeeds the panel lists `\\slow\share` with the cursor on `x.txt` + +#### Scenario: A late reply is discarded +- **WHEN** a check for `\\slow\share\x.txt` is outstanding and the user presses Esc before it answers +- **THEN** the reply neither navigates nor sets an error + +#### Scenario: Reducer makes no filesystem call +- **WHEN** a unit test applies Enter for `D:\Proj\src\main.rs` with neither panel at `D:\Proj\src` +- **THEN** the returned effects contain a path-probe request for that path with a request id, the panel's `cwd` is unchanged, and the result is the same whether or not the file exists on the test machine + +### Requirement: Esc cancels the path box + +While a box has focus, Esc SHALL close it, reset its text to the panel's current directory followed by the separator, discard any completion cycle or outstanding check, and return focus to the panel without opening the quit-confirmation dialog. + +#### Scenario: Esc reverts the box +- **WHEN** the box reads `C:\NORTON\dra` with a cycle active and the user presses Esc +- **THEN** the box shows `C:\NORTON\`, no dialog opens, and the next Esc requests quit as usual + +#### Scenario: Esc discards an outstanding check +- **WHEN** a `Checking …` note is showing for the box's panel and the user presses Esc +- **THEN** the note clears, the box closes, and the check's later reply changes nothing diff --git a/openspec/changes/path-box/specs/pulldown-menus/spec.md b/openspec/changes/path-box/specs/pulldown-menus/spec.md new file mode 100644 index 0000000..76dbcac --- /dev/null +++ b/openspec/changes/path-box/specs/pulldown-menus/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Menu contents + +The system SHALL populate the five menus with their defined items: Left and Right SHALL each offer Brief, Full, Tree, Quick view, Info, a separator, Name, Extension, Modif. time, Size, Unsorted, a separator, Filter, Re-read, Drive select, a separator, New tab, Close tab (mirroring each other); Files SHALL offer View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, Quit; Commands SHALL offer Find file, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options SHALL offer Themes. Every listed item SHALL be enabled; the disabled-item rendering rule ("Pull-down visuals with separators and disabled items") remains available for future items but no shipped item uses it. + +#### Scenario: Files menu lists its items + +- **WHEN** the user opens the `Files` menu +- **THEN** the pull-down lists View, Edit, Copy, Rename/Move, Make directory, Delete, a separator, Copy to clipboard, Copy path(s), Copy name(s), a separator, Select group, Deselect group, Invert selection, a separator, and Quit, all enabled + +#### Scenario: Left and Right menus mirror each other + +- **WHEN** the user opens the `Left` menu and then the `Right` menu +- **THEN** both list the same display-mode, sort-mode, Filter, Re-read, Drive select, New tab, and Close tab items, all enabled + +#### Scenario: Commands and Options menus + +- **WHEN** the user opens `Commands` and then `Options` +- **THEN** Commands lists Find file, Swap panels, Panels on/off, Fuzzy jump, Menu file edit; Options lists Themes; none is disabled + +#### Scenario: Removed items are absent + +- **WHEN** the user opens every menu +- **THEN** no menu lists Attributes, Compare directories, Configuration, Editor selection, History, or Save setup diff --git a/openspec/changes/path-box/specs/quick-filter/spec.md b/openspec/changes/path-box/specs/quick-filter/spec.md new file mode 100644 index 0000000..86b1a75 --- /dev/null +++ b/openspec/changes/path-box/specs/quick-filter/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Cursor and mini-status behavior under an active filter + +The system SHALL keep the panel cursor positioned on a currently visible (matching) entry while a quick filter is active. When the entry under the cursor is filtered out by a pattern change, the system SHALL move the cursor to the nearest remaining visible entry. + +When the pattern leaves no entry visible, the panel SHALL have no current entry. This can happen only where the listing has no `..` parent entry, such as a drive root. While there is no current entry, every command that acts on the entry under the cursor SHALL do nothing. That covers: + +- Enter; +- F3 and F4; +- F5, F6, F8, and the Delete key when no entries are selected; +- Ins; +- opening the file-action menu. + +No command that targets the entry under the cursor SHALL act on an entry the filter hides. Copy, Move, and Delete of selected entries are governed by selection "Operations disclose selected entries hidden by the quick filter" instead. When the pattern changes so that at least one entry is visible again, the cursor SHALL move to the nearest visible entry. + +#### Scenario: Cursor moves to a visible entry when its target is filtered out + +- **WHEN** the cursor is on an entry and the user extends the pattern so that entry no longer matches +- **THEN** the cursor moves to a still-visible matching entry rather than a hidden one + +#### Scenario: Navigation is restricted to matching entries + +- **WHEN** a quick filter is active and the user moves the cursor with the arrow keys +- **THEN** the cursor traverses only the visible matching entries (and `..`), skipping filtered-out entries + +#### Scenario: No visible entry at a drive root means no current entry + +- **WHEN** the active panel shows the root of drive `C:` (no `..` entry), the cursor is on `pagefile.sys`, and the user types a quick-filter pattern that matches no entry +- **THEN** the panel body is empty and the panel has no current entry + +#### Scenario: Cursor-targeted operations do nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry, nothing is selected, and the user presses F8, F5, F6, F3, F4, or Enter +- **THEN** no dialog, viewer, editor, or menu opens, and no file is affected + +#### Scenario: Ins does nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry and the user presses Ins +- **THEN** the selection set is unchanged + +#### Scenario: Widening the pattern restores a current entry + +- **WHEN** a quick filter matches no entry and the user presses Backspace until the pattern matches `notes.txt` +- **THEN** the cursor is on a visible entry and cursor-targeted commands act on that entry again diff --git a/openspec/changes/path-box/specs/responsive-layout/spec.md b/openspec/changes/path-box/specs/responsive-layout/spec.md new file mode 100644 index 0000000..4b42134 --- /dev/null +++ b/openspec/changes/path-box/specs/responsive-layout/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Degraded band and per-panel breakpoints + +Between the 60×16 hard floor and the 80×24 nominal size, the system SHALL keep every feature available while degrading rendering per the rules of this capability; no surface may disappear, silently truncate mid-label, or paint outside the terminal. All panel-content degradation decisions (Full-mode column ladder, Brief column count, mini-status fields) SHALL be functions of the individual panel's width, not the terminal's, and degradation SHALL be deterministic and reversible: growing a surface back restores exactly the richer rendering it had at that size before. + +#### Scenario: All features remain available in the degraded band + +- **WHEN** the terminal is 70×20 +- **THEN** the panels, path boxes, and F-key bar all render and every command that works at 80×24 remains invocable + +#### Scenario: Degradation keys off the panel, not the terminal + +- **WHEN** the terminal is 100 columns wide with the split adjusted so the left panel is 24 columns and the right panel is 76 columns +- **THEN** the left panel renders with a reduced column set per its own width while the right panel renders all four Full-mode columns + +#### Scenario: Degradation is reversible + +- **WHEN** a panel shrinks so Full mode drops the Time column and is later restored to its prior width +- **THEN** the Time column returns and the panel renders exactly as it did at that width before + +### Requirement: Chrome degradation + +The clock SHALL render over the right panel's top border only when it fits without touching the centered path title; otherwise it SHALL be hidden entirely and never partially drawn. A focused path box SHALL scroll horizontally to keep the caret visible; an unfocused path box whose directory exceeds its width SHALL truncate from the left with a leading `…`. The panel mini-status SHALL drop the same fields in the same order as the Full-mode column ladder (Time, then Date, then Size), truncating the entry name last with `…`; the `N files selected, X bytes` summary SHALL truncate with `…` when it does not fit. + +#### Scenario: Clock hides rather than colliding + +- **WHEN** the right panel's top border is too narrow for the clock and the centered path title to both fit +- **THEN** the clock is not drawn at all and the path title renders normally + +#### Scenario: Command line keeps the caret visible + +- **WHEN** the text in a focused path box exceeds the box width +- **THEN** the visible window scrolls so the caret remains on-screen + +#### Scenario: Mini-status drops fields in ladder order + +- **WHEN** a panel is narrow enough that its mini-status cannot show name, size, date, and time +- **THEN** time is dropped first, then date, then size, and the name truncates with `…` only after all other fields are gone + +#### Scenario: Unfocused path box truncates from the left + +- **WHEN** a panel's directory is wider than its unfocused path box +- **THEN** the box shows a leading `…` followed by the tail of the path, and nothing paints outside the box diff --git a/openspec/changes/path-box/specs/startup-splash/spec.md b/openspec/changes/path-box/specs/startup-splash/spec.md new file mode 100644 index 0000000..b3a91a0 --- /dev/null +++ b/openspec/changes/path-box/specs/startup-splash/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Minimum hold and key dismissal + +The system SHALL hold the splash for a minimum of 800 ms measured via the injected `Clock`, after which it is replaced by the panels even if the initial listing is still streaming. Any key press SHALL dismiss the splash immediately without waiting for the minimum hold, and the dismissing key event SHALL be consumed — never forwarded to the path boxes or panels. + +#### Scenario: Minimum hold elapses with no input + +- **WHEN** no key is pressed and 800 ms have elapsed on the injected `Clock` since the splash first painted +- **THEN** the splash is replaced by the panels +- **AND** the panels render even if the initial directory listing is still streaming + +#### Scenario: Key press dismisses before the minimum hold + +- **WHEN** a key is pressed 200 ms after the splash first painted +- **THEN** the splash is dismissed immediately, before the 800 ms minimum hold elapses + +#### Scenario: Dismissing key is consumed + +- **WHEN** a key press dismisses the splash +- **THEN** that key event is consumed and is not forwarded to the path boxes or the panels diff --git a/openspec/changes/path-box/specs/text-field-editing/spec.md b/openspec/changes/path-box/specs/text-field-editing/spec.md new file mode 100644 index 0000000..b380dd0 --- /dev/null +++ b/openspec/changes/path-box/specs/text-field-editing/spec.md @@ -0,0 +1,84 @@ +## MODIFIED Requirements + +### Requirement: Caret model and editing keys + +Every single-line text field — the Copy/Move destination and Make-directory name field, the file-action Rename field, the overwrite-conflict Rename field, the find-file pattern, the fuzzy-jump pattern, and the panels' path box — SHALL be backed by one shared line-edit model holding the text and a caret position. Printable characters SHALL be inserted at the caret; Backspace SHALL delete the character before the caret and Delete the character after it; Left/Right SHALL move the caret by one character; Home/End SHALL move it to the start/end; Ctrl+Left/Ctrl+Right SHALL move it by one word; Ctrl+Backspace/Ctrl+Delete SHALL delete the word before/after the caret. A field that opens pre-filled SHALL open with the caret at the end of the text. The model SHALL live in `filecommand-core` with all mutations flowing through `core::update`. + +#### Scenario: Insert in the middle of a pre-filled name +- **WHEN** the Rename field opens pre-filled with `draft.txt`, and the user presses Left five times, Backspace, then types `i` +- **THEN** the field reads `drift.txt` with the caret after the `i` + +#### Scenario: Home then edit a destination prefix +- **WHEN** the destination field holds `D:\backup\2026`, and the user presses Home, Delete twice, then types `\\server\share` +- **THEN** the field reads `\\server\share\backup\2026` + +#### Scenario: Word delete +- **WHEN** the field holds `C:\Users\kevin\docs` with the caret at the end and the user presses Ctrl+Backspace +- **THEN** the field reads `C:\Users\kevin\` + +#### Scenario: Pre-filled field accepts unchanged with Enter +- **WHEN** the destination field opens pre-filled with the opposite panel's path and the user presses Enter without any editing key +- **THEN** the operation starts against that path exactly as before this capability existed + +### Requirement: Paste into a text field + +The system SHALL paste the system clipboard's text at the caret of the focused dialog text field on Shift+Ins or Ctrl+V, with any line breaks replaced by a single space. When the clipboard holds no text, the key SHALL have no effect. While a path box has focus, Ctrl+V and Shift+Ins SHALL paste into it. Over the panels with no path box focused, Ctrl+V SHALL have no effect, Shift+Ins SHALL keep its selection-toggle meaning, and Ctrl+Shift+Ins SHALL remain the clipboard-paths action. + +#### Scenario: Paste a path into the destination field +- **WHEN** the clipboard holds `E:\archive` and the destination field is focused with the caret at the end of `D:\` +- **THEN** after Ctrl+V the field reads `D:\E:\archive` with the caret at the end + +#### Scenario: Multi-line clipboard text is flattened +- **WHEN** the clipboard holds two lines and the user presses Ctrl+V in a focused path box +- **THEN** the two lines are inserted into the path box joined by one space, and the panel cursor does not move + +#### Scenario: Shift+Ins over the panels still toggles selection +- **WHEN** the clipboard holds text and the user presses Shift+Ins with the panels focused and no path box focused +- **THEN** the cursor entry's selection is toggled and nothing is pasted + +#### Scenario: Empty clipboard is a no-op +- **WHEN** the clipboard holds no text and the user presses Ctrl+V in a focused field +- **THEN** the field and caret are unchanged + +#### Scenario: Ctrl+V over unfocused panels does nothing +- **WHEN** the clipboard holds text, no path box has focus, and the user presses Ctrl+V over the panels +- **THEN** nothing is pasted and the panel cursor does not move + +### Requirement: Terminal paste arrives as one paste + +On platforms where FileCommand's terminal input layer reports a bracketed paste as a single event — every supported platform except Windows — the system SHALL enable bracketed-paste mode while it owns the terminal, SHALL disable it before suspending for a shell command, the external editor, or the scrollback view and on every exit path including a panic, and SHALL re-enable it on resume. A reported paste received while a single-line text field owns the keyboard — the same field Ctrl+V would paste into, including a focused path box — SHALL be inserted at that field's caret as one paste operation, with line breaks replaced by a single space, exactly as "Paste into a text field" inserts clipboard text. A paste into the built-in editor's text SHALL be inserted at the caret as if typed, line breaks included; a paste into the editor's or the viewer's search or replace prompt SHALL be entered as if typed, with line breaks replaced by a single space. A paste received while no text input has focus SHALL be ignored. On Windows the system SHALL NOT enable bracketed-paste mode; a paste arrives as individual key events, which SHALL be handled as typed input, rendered as described in application-shell "Bursts of key input render once". The single-paste guarantees of this requirement, including flattening line breaks and ignoring pastes outside text input, therefore apply only off Windows. + +#### Scenario: Paste a path into the command line + +- **WHEN** a path box has focus and the terminal reports a paste of `C:\Program Files\App` +- **THEN** the path box contains `C:\Program Files\App` inserted at its caret in one operation, and the panel cursor, selection, and quick search are unaffected by the pasted characters + +#### Scenario: Multi-line paste is flattened and not executed + +- **WHEN** a path box has focus and the terminal reports a paste of two lines +- **THEN** the two lines are inserted joined by one space and nothing is selected or navigated + +#### Scenario: Paste into a dialog field + +- **WHEN** the Copy destination field is focused with the caret at the end of `D:\` and the terminal reports a paste of `backup` +- **THEN** the field reads `D:\backup` with the caret at the end + +#### Scenario: Paste with no focused text input is ignored + +- **WHEN** the delete confirmation dialog is open and the terminal reports a paste of `y` +- **THEN** nothing is deleted and the dialog stays open + +#### Scenario: Child programs get a terminal without bracketed paste + +- **WHEN** off Windows, the user runs a command from the F2 user menu, which suspends FileCommand while it runs +- **THEN** bracketed-paste mode is disabled while the command runs and re-enabled when FileCommand resumes + +#### Scenario: On Windows a paste is typed input + +- **WHEN** FileCommand runs on Windows and the user pastes `C:\Program Files\App` while a path box has focus +- **THEN** bracketed-paste mode was never enabled, the characters are inserted as typed keys in order, and the screen is redrawn once per burst of keys rather than once per character + +#### Scenario: Paste over unfocused panels is ignored + +- **WHEN** off Windows, no path box has focus and the terminal reports a paste of `C:\Program Files\App` over the panels +- **THEN** nothing changes: no box text, no cursor movement, no search diff --git a/openspec/changes/path-box/specs/theme-selection/spec.md b/openspec/changes/path-box/specs/theme-selection/spec.md new file mode 100644 index 0000000..aa0e567 --- /dev/null +++ b/openspec/changes/path-box/specs/theme-selection/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Live theme preview while the picker is open + +While the theme picker is open, the system SHALL render every surface of the frame — panels, key bar, path boxes, clock, the picker dialog itself, and any overlay drawn above it — by resolving roles through the currently highlighted theme's role table, updating on the next frame after every highlight move. The preview SHALL be render-only: the active theme, the persisted configuration, and the picker's active-theme marker SHALL continue to reflect the applied theme until Enter is pressed. Closing the picker without applying SHALL restore rendering through the applied theme. + +#### Scenario: Moving the highlight previews the theme + +- **WHEN** the active theme is `nc-classic`, the picker is open, and the user moves the highlight to `purple-lights` +- **THEN** the next frame renders every element by resolving roles in the `purple-lights` table +- **AND** the active theme remains `nc-classic` and `config.toml` is not written + +#### Scenario: Esc restores the applied theme's rendering + +- **WHEN** the picker is open with `yellow-storm` highlighted and previewed, and the user presses Esc +- **THEN** the dialog closes and the next frame renders entirely through the applied theme's role table + +#### Scenario: The marker stays on the applied theme during preview + +- **WHEN** the active theme is `nc-classic` and the highlight is on `inverted` +- **THEN** the frame renders in `inverted` while the picker's active-theme marker remains on the `nc-classic` row + +#### Scenario: Opening the picker changes nothing visually + +- **WHEN** the picker opens with the highlight on the active theme +- **THEN** the rendered frame is identical to the frame before the picker opened, apart from the picker dialog itself diff --git a/openspec/changes/path-box/specs/type-ahead-jump/spec.md b/openspec/changes/path-box/specs/type-ahead-jump/spec.md new file mode 100644 index 0000000..1cdab91 --- /dev/null +++ b/openspec/changes/path-box/specs/type-ahead-jump/spec.md @@ -0,0 +1,69 @@ +## RENAMED Requirements + +- FROM: `### Requirement: Exiting type-ahead and restoring command-line routing` +- TO: `### Requirement: Exiting type-ahead` + +## MODIFIED Requirements + +### Requirement: Entering type-ahead mode with Alt+letter + +The system SHALL enter panel type-ahead quick-search mode when the user presses Alt+letter while a panel is focused, seeding the search pattern with that letter and moving the panel cursor to the first entry that matches the pattern. + +#### Scenario: Alt+letter starts a search and jumps to the first match + +- **WHEN** a panel is focused, no type-ahead is active, and the user presses `Alt+r` +- **THEN** type-ahead mode becomes active with pattern `r` +- **AND** the panel cursor moves to the first entry whose name matches the pattern `r` + +#### Scenario: Alt+letter with no matching entry + +- **WHEN** a panel is focused with no entry matching the pressed letter and the user presses `Alt+z` +- **THEN** type-ahead mode becomes active with pattern `z` +- **AND** the panel cursor does not move from its current position + +#### Scenario: Alt+letter is not routed to the command line + +- **WHEN** a panel is focused and the user presses `Alt+letter` to start type-ahead +- **THEN** the letter is consumed by type-ahead and is not inserted anywhere else + +### Requirement: Extending the pattern with printable keys + +While type-ahead mode is active, the system SHALL append plain printable keys to the search pattern and re-move the panel cursor to the first entry matching the extended pattern; plain printable keys have no other meaning over the panels. + +#### Scenario: A plain printable key extends the pattern + +- **WHEN** type-ahead is active with pattern `r` and the user presses `e` +- **THEN** the pattern becomes `re` +- **AND** the panel cursor moves to the first entry matching `re` + +#### Scenario: Extended pattern no longer matches + +- **WHEN** type-ahead is active with pattern `re` matching an entry and the user presses a printable key that makes the pattern match no entry +- **THEN** the pattern includes the new key +- **AND** the panel cursor holds its current position + +#### Scenario: Printable keys do not reach the command line while active + +- **WHEN** type-ahead is active and the user types plain printable keys +- **THEN** those keys extend the search pattern only +- **AND** none of them are inserted anywhere else + +### Requirement: Exiting type-ahead + +The system SHALL exit type-ahead mode when the user presses any panel movement key, after which plain printable keys typed over the focused panel SHALL be ignored. Esc SHALL NOT exit type-ahead; over the panels it requests application quit, and cancelling that dialog leaves type-ahead active (application-shell "Quit request keys and confirmation"). + +#### Scenario: A movement key exits type-ahead and is applied to the panel + +- **WHEN** type-ahead is active and the user presses a movement key (e.g. Down) +- **THEN** type-ahead mode ends +- **AND** the movement key is applied to the panel cursor as a normal movement + +#### Scenario: Esc leaves type-ahead active + +- **WHEN** type-ahead is active and the user presses Esc, then cancels the quit-confirmation dialog +- **THEN** type-ahead mode is still active with its pattern intact + +#### Scenario: Printable keys return to the command line after exit + +- **WHEN** type-ahead has exited via a movement key and the user then presses a plain printable key while the panel is focused +- **THEN** the key is ignored and does not start or extend a search diff --git a/openspec/changes/path-box/specs/user-menu/spec.md b/openspec/changes/path-box/specs/user-menu/spec.md new file mode 100644 index 0000000..d75badd --- /dev/null +++ b/openspec/changes/path-box/specs/user-menu/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: Navigate and dismiss the user menu + +The system SHALL let the user move the highlight with the Up/Down arrow keys across the `usermenu.toml` entries and the built-in `Themes` entry as one continuous list, never highlighting the separator row, and SHALL close the menu without running any command when Esc is pressed. Movement SHALL clamp at the first user entry and at the built-in entry. While the menu is open it is modal: panel and path-box input SHALL NOT be processed until it closes. + +#### Scenario: Esc closes the menu without running a command + +- **WHEN** the user menu is open and the user presses Esc +- **THEN** the menu closes, no command is dispatched to the shell, and focus returns to the active panel + +#### Scenario: Arrow keys move the highlight across both sections + +- **WHEN** the user menu lists two `usermenu.toml` entries with the first highlighted and the user presses Down twice +- **THEN** the built-in `Themes` entry is highlighted, the separator was never highlighted, and pressing Down again leaves `Themes` highlighted diff --git a/openspec/changes/path-box/tasks.md b/openspec/changes/path-box/tasks.md new file mode 100644 index 0000000..8c15cf7 --- /dev/null +++ b/openspec/changes/path-box/tasks.md @@ -0,0 +1,41 @@ +# Tasks: path-box + +## 1. Core: remove the command line + +- [ ] 1.1 Delete `State.command_line`, `history`, `history_cursor` and `State::prompt()`; delete `Command::{CommandLineClear, CommandLineHistoryPrev, CommandLineHistoryNext, PasteCursorName, PasteCursorPath, HistoryDialogOpen/…}`, the panels-level `TextEdit` / `RequestPaste` arms, and the `commands` field of `Effect::PersistHistory` (command-line REMOVED; design D7, D8). +- [ ] 1.2 Delete `recall_history`, `paste_cursor_entry`, `run_command_line`, `dispatch_cd`, `complete_cd`, `dispatch_delete_builtin`, `complete_delete_builtin`, `parse_cd` / `parse_del` / `parse_rmdir` / `parse_builtin_arg` and `ProbeIntent::{Cd, Delete}` in `update/panels.rs` and `update/command.rs`; keep `resolve_cd_target`, `parse_drive_relative`, `panels_own_input`, `start_probe` / `apply_path_probed` (reshaped in 2.5). +- [ ] 1.3 Delete `HistoryDialogState`, `handle_history_dialog`, `Overlay::HistoryDialog`, `MenuAction::History`, the Commands → History item and `config::push_history` (pulldown-menus "Menu contents"; application-shell "Modal overlays are mutually exclusive by construction"). +- [ ] 1.4 Remove `Keys.{paste_name, paste_path, clear_line}` and their parsing and serialisation; add `Keys.focus_path` (default `ctrl+g`) with parser tests for the default and the override (path-box "Focusing a path box": "Rebinding the focus key"; design D9). +- [ ] 1.5 The "Command line" help topic becomes "Path box" with new text (help-and-about "Help topic list": "Path box topic"). +- [ ] 1.6 Update or delete the affected reducer tests in `update/tests/{panels,file_op,listing,overlays,routing}.rs`, `config.rs`, `menu.rs`. + +## 2. Core: path box state and reducer + +- [ ] 2.1 `path_box.rs`: `PathBoxState { side, edit: LineEdit, completion: Option, probe: Option }`, `Completion { dir, prefix, names: Option>, candidates: Vec, index: Option, request: Option }`; pure helpers `split_last_segment`, `candidates_for(names, prefix)` (directories first, then case-insensitive name — its own comparator, not `cmp_entries`), `apply_candidate` with unit tests (design D3). +- [ ] 2.2 Commands `PathBoxFocus(side)`, `PathBoxEdit(TextEditOp)`, `PathBoxComplete { reverse }`, `PathBoxAccept`, `PathBoxCancel`, `PathBoxPaste(String)`, `CompletionsListed { request, dir, names }`; `Effect::ListCompletions { request, dir }`; `ProbeIntent::Select { parent, name }`. +- [ ] 2.3 Focus: set `path_box`, end type-ahead, make `side` active, text = cwd + separator, caret at end (path-box "Focusing a path box"; design D2). +- [ ] 2.4 Completion per design D3: listing-sourced and worker-sourced candidates, directories first, cycle and wrap, directory separator, prefix frozen for the cycle, cache kept on edits within the folder, stale reply and ignored-while-reading rules, `names: None` → `no matches` (path-box "Tab completes the last path segment", "Completion candidates and cycling order", "Completion cycle and mini-status", "Completion candidates come from a panel listing when possible", "Completion reads run off the event loop"). +- [ ] 2.5 Accept per design D4: cwd no-op, root entry, listed parent immediate (this or the other panel), probe with `Checking …`, reply guard (same side, same text, current id), success navigates/lands/closes, failure names the path and keeps focus; Cancel resets and closes and drops the outstanding probe (path-box "Enter resolves the path box text", "Enter selects the named entry", "Enter on the panel's own directory or a root", "A listed parent answers the existence check", "The existence check never blocks the event loop", "Esc cancels the path box"). +- [ ] 2.6 `ListingComplete`: exact-then-case-insensitive match for `pending_cursor_target` (design D5); find-file tests still pass. +- [ ] 2.7 Close the box whenever a layer above the panels opens (design D1): file-operation dialogs, overlays, the startup warning. +- [ ] 2.8 Reducer tests for every `path-box` scenario, run with no filesystem (application-shell "The reducer performs no filesystem I/O": all four path-box scenarios). +- [ ] 2.9 If `auto-refresh` is already merged: add `path_box.is_none()` to its probe eligibility with a test (design D10). + +## 3. TUI + +- [ ] 3.1 `input/mod.rs`: `map_path_box_key` (text-edit set, Ctrl+V / Shift+Ins, Tab, BackTab, Enter, Esc; everything else ignored) routed before `map_panel_key` whenever `state.path_box.is_some()`; `map_panel_key` drops `typing` and every command-line arm (design D7); `keys.focus_path` → `PathBoxFocus(active)`; `map_paste` targets the focused box or is ignored (text-field-editing deltas). +- [ ] 3.2 Mouse: click on a box → `PathBoxFocus(side)`; click elsewhere while focused → `PathBoxCancel` then normal handling; hit map `path_boxes: [Rect; 2]` (path-box "A focused path box owns the keyboard": "A click outside the box cancels it"). +- [ ] 3.3 `views/path_box.rs` replaces `views/command_line.rs` (design D6): two boxes on the `cmdline` row split at the panel divider, unfocused in `Role::CommandLine` with left truncation (promote `left_truncate_with_ellipsis` from `command_line.rs` / `header.rs` to one shared helper), focused via `draw_text_field` returning the caret; `views/mod.rs` call site and cursor ownership; mini-status override for `k/n matches` / `no matches` / `Reading…` in `views/panel.rs`. +- [ ] 3.4 `worker.rs`: `spawn_completion_list` using `listing::list_children_for_completion` (every readable child, no `..`); `app.rs` runs `Effect::ListCompletions`; `spawn_path_probe` unchanged. +- [ ] 3.5 `app.rs`: stop loading `history.commands` into state; `history_writer` keeps the file's `commands` array (design D8); remove `views/history_dialog.rs` and its routing. +- [ ] 3.6 Tests: `input/tests.rs` (box keys, panel keys no longer typing-dependent, Ctrl+G, rebinding, unbound keys ignored in the box), `input/mouse/tests.rs`, the `app.rs` burst test retargeted to a focused box, `snapshot_views.rs` (unfocused boxes both sides, focused box with caret, long-path truncation, matches mini-status, `Reading…`), regenerate the full-frame snapshots, `tests/integration_smoke.rs`. + +## 4. Docs + +- [ ] 4.1 README: replace the "Command line" section and its "History" subsection with "Path box"; key tables (Ctrl+G; Tab / Shift+Tab / Enter / Esc in the box; drop Ctrl+Enter, Ctrl+], Ctrl+Y, Alt+F8); `key.*` list; note the removed builtins and that Ctrl+O is unchanged. +- [ ] 4.2 F1 Help: "Path box" topic page; keyboard reference drops Alt+F8 and the paste keys; configuration topic lists `key.focus_path` and drops `key.clear_line`. + +## 5. Verify + +- [ ] 5.1 `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean; `openspec validate path-box --strict` clean. +- [ ] 5.2 Manual: Ctrl+G, type `..\`, Tab cycles siblings; type a prefix, Tab, Enter → cursor lands; `D:\` Enter → enters the root; a UNC share shows `Reading…` / `Checking …` without freezing; Esc reverts; Tab over the panels still switches; Backspace still goes up; `+` still opens group select; an old config with `key.clear_line` warns at startup.