From 1237294f516bb5295d848758c9739715ca907d22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 01:25:32 +0000 Subject: [PATCH] feat(tui): add :bookmark/:bookmarks command and Shift+B picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.2 of the v0.2 improvement plan — bookmarks & quick access: - `:bookmark ` saves the last successful command under a name; `:bookmark --remove ` deletes one; `:bookmarks` lists all of them. Backed by a new src/kairos/services/bookmarks.py, persisted as a flat JSON array at .kairos/.bookmarks.json. Unlike command history (which fails closed since it's passive/background), saving/removing a bookmark is a deliberate action, so I/O errors here surface as a normal KairosError through the existing status-line path. - Shift+B opens a picker (BookmarkPickerScreen): Enter re-runs the highlighted bookmark's command, `d` removes it, Escape closes. - The tab bar shows the 3 most-recently-saved bookmarks, refreshed on every :bookmark/:bookmarks dispatch and loaded at startup (best-effort — a corrupt .bookmarks.json degrades to "no bookmarks shown" rather than crashing the app before the UI can report anything). - docs/tli.md and the in-app :help screen updated with the new commands/keybinding, and backfilled a few Phase-1 items that were missing from docs/tli.md (:ingest, :tutorial, Ctrl+G, persisted command history). Also fixes a real pre-existing bug found while wiring the picker's result handler: `on_list_view_selected` called `MainScreen.refresh_from_state(None, new_state)` on every Explorer selection, and `refresh_from_state`'s `old is None` branch unconditionally re-appends the last activity entry to the Workspace transcript — so every single Explorer click/Enter was duplicating the previous command's entire result table into the transcript. Fixed by passing the real prior state instead of `None`; added a regression test (test_selecting_in_explorer_does_not_duplicate_workspace_transcript). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JoRVBGw6Ea7wd8d1ZDFKAx --- docs/tli.md | 35 ++- src/kairos/domain/errors.py | 4 + src/kairos/schemas/bookmark.py | 13 ++ src/kairos/services/bookmarks.py | 72 ++++++ src/kairos/tui/app.py | 49 ++++- src/kairos/tui/commands.py | 4 + src/kairos/tui/controller.py | 63 ++++++ src/kairos/tui/screens/bookmark_picker.py | 76 +++++++ src/kairos/tui/screens/help.py | 25 ++- src/kairos/tui/state.py | 7 + src/kairos/tui/styles/kairos.tcss | 33 +++ src/kairos/tui/widgets/tab_bar.py | 18 +- tests/tui/test_app.py | 23 ++ tests/tui/test_bookmarks.py | 255 ++++++++++++++++++++++ 14 files changed, 661 insertions(+), 16 deletions(-) create mode 100644 src/kairos/schemas/bookmark.py create mode 100644 src/kairos/services/bookmarks.py create mode 100644 src/kairos/tui/screens/bookmark_picker.py create mode 100644 tests/tui/test_bookmarks.py diff --git a/docs/tli.md b/docs/tli.md index 2a370d6..8571859 100644 --- a/docs/tli.md +++ b/docs/tli.md @@ -87,29 +87,56 @@ Responsive to terminal width: :doctor workspace health checks (inspect only) :note list notes on an artifact or span :note add add a note (the only free-text mutation) -:history this session's command log +:ingest [path] [-r] ingest files (default: workspace root) +:history this session's command log, persisted across restarts +:history --clear wipe command history (in-session and on disk) +:bookmark save the last command as a named bookmark +:bookmark --remove delete a saved bookmark +:bookmarks list saved bookmarks +:tutorial open the guided tutorial overlay :help (or bare ?) help overlay :refresh (or key r) re-run the last successful command :quit (or :q) quit ``` Unknown commands produce an actionable error naming the closest valid -command — never a traceback. +command — never a traceback. The command line also offers ghost-text +completion of command names as you type, and a one-line hint below it +describing whatever command you're currently typing. + +## Command history + +Every submitted command line — successful or not — is recorded to +`.kairos/.tui_history` (append-only JSON Lines) and survives restarts. +`Up`/`Down` in the command line cycle through it (most recent first); +`:history --clear` wipes both the in-session and on-disk copies. + +## Bookmarks + +`:bookmark ` saves the *previous* successful command under a name; +`:bookmarks` lists everything saved; `Shift+B` opens a picker (`Enter` runs +the highlighted bookmark's command, `d` removes it, `Escape` closes). +Bookmarks are stored at `.kairos/.bookmarks.json`; the tab bar always shows +the three most recently saved. ## Keybindings | Key | Action | |---|---| -| `Ctrl+P` | Focus the command line | +| `Ctrl+P` | Fuzzy finder across all ingested artifacts | +| `Ctrl+G` | "Go to item #" in the Explorer pane | | `Ctrl+R` | Open the history overlay | | `Tab` / `Shift+Tab` | Cycle focus: Explorer → Workspace → Evidence → command line | | `Enter` | Run a command, or inspect the highlighted Explorer item | -| `Up` / `Down` | Move the Explorer selection | +| `Up` / `Down` | Move the Explorer selection; cycle command history in the command line; scroll the Evidence pane when it has focus | +| `Page Up` / `Page Down`, `Home`, `End` | Scroll the Evidence pane when it has focus | | `/` | Start a `:search ` in the command line | | `w` | Open the coherence-well picker | +| `Shift+B` | Open the bookmark picker | | `c` | Copy the current citation (plain text, terminal clipboard escape — no shell command) | | `y` | Copy the current source excerpt (same mechanism as `c`) | | `r` | Re-run the last successful command (not while typing) | +| `t` | Open the guided tutorial | | `?` | Help overlay | | `q` | Quit (not while the command line has focus) | | `Escape` | Close an overlay | diff --git a/src/kairos/domain/errors.py b/src/kairos/domain/errors.py index 8c13846..1e14df4 100644 --- a/src/kairos/domain/errors.py +++ b/src/kairos/domain/errors.py @@ -59,6 +59,10 @@ class WellMemberNotFoundError(KairosError): """Raised by ``kairos well remove`` when the member is not in the well.""" +class BookmarkNotFoundError(KairosError): + """Raised when a TUI bookmark name does not exist.""" + + class TargetNotFoundError(KairosError): """Raised when a note or well-add target id does not resolve to an artifact or span.""" diff --git a/src/kairos/schemas/bookmark.py b/src/kairos/schemas/bookmark.py new file mode 100644 index 0000000..0b6fd6b --- /dev/null +++ b/src/kairos/schemas/bookmark.py @@ -0,0 +1,13 @@ +"""Result schema for TUI bookmarks — named, saved commands for quick recall.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class BookmarkResult(BaseModel): + name: str + command: str + created_at: datetime diff --git a/src/kairos/services/bookmarks.py b/src/kairos/services/bookmarks.py new file mode 100644 index 0000000..19be60e --- /dev/null +++ b/src/kairos/services/bookmarks.py @@ -0,0 +1,72 @@ +"""TUI bookmarks — named, saved commands for quick recall via ``:bookmark``. + +Stored as a single JSON array at ``.kairos/.bookmarks.json``, unlike command +history's append-only JSONL: bookmarks are a small, named, mutable set (save +overwrites, remove deletes), not an ever-growing log, so a whole-file +read/write is the simpler and correct model. Saving/removing a bookmark is a +deliberate user action, so — unlike command history, which fails closed — +I/O errors here are raised as ``KairosError`` so the user actually finds out +their bookmark didn't save. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +from kairos.domain.errors import BookmarkNotFoundError, KairosError +from kairos.schemas.bookmark import BookmarkResult +from kairos.services.context import RuntimeContext + +BOOKMARKS_FILENAME = ".bookmarks.json" + + +def bookmarks_file_path(workspace_root: Path) -> Path: + return workspace_root / ".kairos" / BOOKMARKS_FILENAME + + +def list_bookmarks(ctx: RuntimeContext) -> list[BookmarkResult]: + """Oldest-saved first — mirrors command_history's ordering convention. + Callers that want "most recent" (e.g. the tab bar) take from the end. + """ + path = bookmarks_file_path(ctx.workspace.root) + if not path.exists(): + return [] + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise KairosError(f"Could not read bookmarks: {exc}") from exc + return [ + BookmarkResult(name=b["name"], command=b["command"], created_at=b["created_at"]) + for b in raw + ] + + +def _write_bookmarks(workspace_root: Path, bookmarks: list[BookmarkResult]) -> None: + path = bookmarks_file_path(workspace_root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + payload = [b.model_dump(mode="json") for b in bookmarks] + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + except OSError as exc: + raise KairosError(f"Could not save bookmarks: {exc}") from exc + + +def save_bookmark(ctx: RuntimeContext, name: str, command: str) -> BookmarkResult: + """Save (or overwrite) a bookmark. Re-saving an existing name moves it to + the end of the list, so it counts as the most recent for the tab bar. + """ + bookmarks = [b for b in list_bookmarks(ctx) if b.name != name] + new_bookmark = BookmarkResult(name=name, command=command, created_at=datetime.now(UTC)) + bookmarks.append(new_bookmark) + _write_bookmarks(ctx.workspace.root, bookmarks) + return new_bookmark + + +def remove_bookmark(ctx: RuntimeContext, name: str) -> None: + bookmarks = list_bookmarks(ctx) + remaining = [b for b in bookmarks if b.name != name] + if len(remaining) == len(bookmarks): + raise BookmarkNotFoundError(f"No bookmark named {name!r}.") + _write_bookmarks(ctx.workspace.root, remaining) diff --git a/src/kairos/tui/app.py b/src/kairos/tui/app.py index 053f204..b5af3d5 100644 --- a/src/kairos/tui/app.py +++ b/src/kairos/tui/app.py @@ -17,9 +17,12 @@ from textual.css.query import NoMatches from textual.widgets import Input, ListView +from kairos.schemas.bookmark import BookmarkResult +from kairos.services.bookmarks import list_bookmarks from kairos.services.context import RuntimeContext from kairos.tui import controller from kairos.tui.commands import load_history +from kairos.tui.screens.bookmark_picker import BookmarkPickerScreen from kairos.tui.screens.fuzzy_finder import FuzzyFinderScreen from kairos.tui.screens.goto_line import GotoLineScreen from kairos.tui.screens.help import HelpScreen @@ -30,6 +33,7 @@ from kairos.tui.widgets.evidence_pane import EvidencePane, citation_text, excerpt_text from kairos.tui.widgets.explorer_pane import ExplorerPane from kairos.tui.widgets.status_line import StatusLine +from kairos.tui.widgets.tab_bar import TabBar from kairos.tui.widgets.workspace_pane import WorkspacePane _STYLES_PATH = Path(__file__).parent / "styles" / "kairos.tcss" @@ -51,6 +55,7 @@ class KairosApp(App[None]): Binding("shift+tab", "cycle_focus(true)", "Cycle pane (reverse)", show=False), Binding("slash", "start_search", "Search"), Binding("w", "open_well_picker", "Wells"), + Binding("B", "open_bookmark_picker", "Bookmarks"), Binding("c", "copy_citation", "Copy citation"), Binding("y", "copy_excerpt", "Copy excerpt"), Binding("r", "refresh_view", "Refresh", show=False), @@ -65,10 +70,23 @@ def __init__(self, runtime_ctx: RuntimeContext) -> None: self._request_id = 0 persisted_history = tuple(r.command for r in load_history(runtime_ctx.workspace.root)) self.state = TuiState( - workspace_path=runtime_ctx.workspace.root, command_history=persisted_history + workspace_path=runtime_ctx.workspace.root, + command_history=persisted_history, + recent_bookmarks=tuple(self._load_recent_bookmarks(runtime_ctx)), ) self.layout_mode = "wide" + @staticmethod + def _load_recent_bookmarks(runtime_ctx: RuntimeContext) -> list[BookmarkResult]: + # Best-effort at cold start: a corrupt .bookmarks.json must not crash + # the app before Textual has anything on screen to report it with — + # unlike a live `:bookmarks` dispatch, which surfaces the same error + # through the normal status-line path once the UI is already up. + try: + return list_bookmarks(runtime_ctx)[-3:] + except Exception: + return [] + def on_mount(self) -> None: self.push_screen(MainScreen()) self._apply_layout_mode() @@ -137,12 +155,18 @@ def on_list_view_selected(self, event: ListView.Selected) -> None: if reference is None: return kind, target_id = reference + old_state = self.state new_state = dataclasses.replace( self.state, selection=Selection(kind=kind, id=target_id, origin_view=self.state.mode), ) self.state = new_state - self.query_one(MainScreen).refresh_from_state(None, new_state) + # `old_state`, not `None` — MainScreen.refresh_from_state(None, ...) + # unconditionally re-appends the last activity entry to the Workspace + # transcript. A selection doesn't add an activity entry, so passing + # the real prior state here is what keeps a plain Explorer click from + # duplicating the last command's whole result table into the log. + self.query_one(MainScreen).refresh_from_state(old_state, new_state) # -- actions -------------------------------------------------------------- @@ -193,6 +217,27 @@ def handle_result(result: tuple[str, str | None] | None) -> None: self.push_screen(WellPickerScreen(self.runtime_ctx), handle_result) + def action_open_bookmark_picker(self) -> None: + if isinstance(self.focused, Input): + return + + def handle_result(command: str | None) -> None: + # The picker may have removed a bookmark even on cancel (via its + # own `d` binding), so always resync the tab bar's cached copy — + # not just on a run. Refreshing only the tab bar (not the whole + # MainScreen) matters here: MainScreen.refresh_from_state(None, ...) + # unconditionally re-appends the last activity entry into the + # Workspace transcript, which would duplicate it on every picker + # close that didn't actually run anything. + self.state = dataclasses.replace( + self.state, recent_bookmarks=tuple(self._load_recent_bookmarks(self.runtime_ctx)) + ) + self.query_one(TabBar).refresh_from_state(self.state) + if command is not None: + self.run_command(command) + + self.push_screen(BookmarkPickerScreen(self.runtime_ctx), handle_result) + def action_show_help(self) -> None: self.push_screen(HelpScreen()) diff --git a/src/kairos/tui/commands.py b/src/kairos/tui/commands.py index 6be641b..7bea4dc 100644 --- a/src/kairos/tui/commands.py +++ b/src/kairos/tui/commands.py @@ -35,6 +35,8 @@ "note", "ingest", "tutorial", + "bookmark", + "bookmarks", } ) @@ -127,6 +129,8 @@ def _closest(name: str) -> str | None: "tutorial": "open the guided tutorial overlay", "refresh": "re-run the last successful command", "quit": "quit the TUI", + "bookmark": "save the last command as (--remove to delete)", + "bookmarks": "list saved bookmarks (Shift+B to pick and re-run one)", } diff --git a/src/kairos/tui/controller.py b/src/kairos/tui/controller.py index 84beaa3..30c3343 100644 --- a/src/kairos/tui/controller.py +++ b/src/kairos/tui/controller.py @@ -20,9 +20,11 @@ RelationRow, SourceSpanRow, ) +from kairos.schemas.bookmark import BookmarkResult from kairos.schemas.dashboard import DashboardResult, ParseBreakdown from kairos.services.activity import recent_events from kairos.services.artifacts import list_artifacts as list_artifacts_service +from kairos.services.bookmarks import list_bookmarks, remove_bookmark, save_bookmark from kairos.services.config_query import get_config_symbol from kairos.services.context import RuntimeContext from kairos.services.doctor import run_doctor @@ -55,6 +57,8 @@ "history": "history", "help": "help", "note": "notes", + "bookmark": "bookmarks", + "bookmarks": "bookmarks", } @@ -474,6 +478,63 @@ def _ingest(runtime_ctx: RuntimeContext, state: TuiState, command: Command) -> T ) +_RECENT_BOOKMARKS_SHOWN = 3 + + +def _recent_bookmarks(bookmarks: list[BookmarkResult]) -> tuple[BookmarkResult, ...]: + return tuple(bookmarks[-_RECENT_BOOKMARKS_SHOWN:]) + + +def _bookmark(runtime_ctx: RuntimeContext, state: TuiState, command: Command) -> TuiState: + if not command.args: + raise KairosError("Usage: :bookmark | :bookmark --remove ") + + if command.args[0] == "--remove": + if len(command.args) < 2: + raise KairosError("Usage: :bookmark --remove ") + name = command.args[1] + remove_bookmark(runtime_ctx, name) + bookmarks = list_bookmarks(runtime_ctx) + updated_state = dataclasses.replace(state, recent_bookmarks=_recent_bookmarks(bookmarks)) + return _record( + updated_state, + mode="bookmarks", + command=command.raw, + status="success", + summary=f"removed bookmark {name!r}", + last_result=bookmarks, + ) + + name = command.args[0] + last = next((e for e in reversed(state.activity) if e.status == "success"), None) + if last is None: + raise KairosError("Nothing to bookmark yet — run a command first.") + save_bookmark(runtime_ctx, name, last.command) + bookmarks = list_bookmarks(runtime_ctx) + updated_state = dataclasses.replace(state, recent_bookmarks=_recent_bookmarks(bookmarks)) + return _record( + updated_state, + mode="bookmarks", + command=command.raw, + status="success", + summary=f"bookmarked {last.command!r} as {name!r}", + last_result=bookmarks, + ) + + +def _bookmarks(runtime_ctx: RuntimeContext, state: TuiState, command: Command) -> TuiState: + bookmarks = list_bookmarks(runtime_ctx) + updated_state = dataclasses.replace(state, recent_bookmarks=_recent_bookmarks(bookmarks)) + return _record( + updated_state, + mode="bookmarks", + command=command.raw, + status="success", + summary=f"{len(bookmarks)} bookmark(s)", + last_result=bookmarks, + ) + + _HANDLERS = { "home": _home, "artifacts": _artifacts, @@ -489,4 +550,6 @@ def _ingest(runtime_ctx: RuntimeContext, state: TuiState, command: Command) -> T "well": _well, "note": _note, "ingest": _ingest, + "bookmark": _bookmark, + "bookmarks": _bookmarks, } diff --git a/src/kairos/tui/screens/bookmark_picker.py b/src/kairos/tui/screens/bookmark_picker.py new file mode 100644 index 0000000..a445d4a --- /dev/null +++ b/src/kairos/tui/screens/bookmark_picker.py @@ -0,0 +1,76 @@ +"""Bookmark quick-access overlay (Shift+B): pick a saved bookmark and +re-run its command. Mostly read-only like the well picker, but ``d`` +removes a bookmark on the spot — deleting a saved shortcut isn't source +mutation, unlike wells' create/add-member restriction, so there's no reason +to push it out to the CLI-only surface. +""" + +from __future__ import annotations + +from rich.markup import escape +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import ListItem, ListView, Static + +from kairos.schemas.bookmark import BookmarkResult +from kairos.services.bookmarks import list_bookmarks, remove_bookmark +from kairos.services.context import RuntimeContext + + +class _BookmarkItem(ListItem): + def __init__(self, bookmark: BookmarkResult) -> None: + text = ( + f"{escape(bookmark.name)}\n" + f"[dim]{escape(bookmark.command)} · " + f"saved {bookmark.created_at.isoformat(timespec='seconds')}[/dim]" + ) + super().__init__(Static(text)) + self.bookmark = bookmark + + +class BookmarkPickerScreen(ModalScreen[str | None]): + """Dismisses with the chosen bookmark's command string, or ``None``.""" + + BINDINGS = [ + ("escape", "cancel", "Close"), + ("d", "remove_selected", "Remove bookmark"), + ] + + def __init__(self, runtime_ctx: RuntimeContext) -> None: + super().__init__() + self._runtime_ctx = runtime_ctx + + def compose(self) -> ComposeResult: + with Vertical(id="bookmark-picker-container"): + yield Static("Bookmarks — Enter runs, d removes, Escape closes") + yield ListView(id="bookmark-picker-list") + + def on_mount(self) -> None: + self._refresh_list() + + def _refresh_list(self) -> None: + list_view = self.query_one("#bookmark-picker-list", ListView) + list_view.clear() + # Most-recently-saved first — matches what the tab bar highlights. + bookmarks = list(reversed(list_bookmarks(self._runtime_ctx))) + for bookmark in bookmarks: + list_view.append(_BookmarkItem(bookmark)) + if bookmarks: + list_view.index = 0 + list_view.focus() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, _BookmarkItem): + self.dismiss(item.bookmark.command) + + def action_remove_selected(self) -> None: + list_view = self.query_one("#bookmark-picker-list", ListView) + item = list_view.highlighted_child + if isinstance(item, _BookmarkItem): + remove_bookmark(self._runtime_ctx, item.bookmark.name) + self._refresh_list() + + def action_cancel(self) -> None: + self.dismiss(None) diff --git a/src/kairos/tui/screens/help.py b/src/kairos/tui/screens/help.py index fdbd58d..0b0cab0 100644 --- a/src/kairos/tui/screens/help.py +++ b/src/kairos/tui/screens/help.py @@ -26,19 +26,28 @@ :logs log search with locators :doctor workspace health checks :ingest [path] [-r] ingest files (default: workspace root) - :history this session's command log + :history this session's command log, persisted across restarts + :history --clear wipe command history (in-session and on disk) + :bookmark save the last command as a named bookmark + :bookmark --remove delete a saved bookmark + :bookmarks list saved bookmarks :help (or bare ?) this screen :refresh (or r) re-run the last successful command :quit (or :q) quit KEYBINDINGS - Ctrl+P fuzzy finder Tab / Shift+Tab cycle pane focus - Ctrl+R history search / start a search - Enter run / inspect w well selector - Up/Down move selection c copy citation - r re-run last command y copy excerpt - t interactive tutorial ? help - Escape close overlay q quit (not while typing) + Ctrl+P fuzzy finder Tab / Shift+Tab cycle pane focus + Ctrl+G go to item # (Explorer) Ctrl+R history search + / start a search w well selector + Shift+B bookmark picker Up/Down move selection / cycle history + Enter run / inspect c copy citation + r re-run last command y copy excerpt + t interactive tutorial ? help + Escape close overlay q quit (not while typing) + + In the Evidence pane (focused): Up/Down/Page Up/Page Down/Home/End scroll + a long citation excerpt. In the command line: Up/Down cycle through + command history instead of moving the cursor. PROVENANCE LAYERS RAW the ingested bytes themselves diff --git a/src/kairos/tui/state.py b/src/kairos/tui/state.py index 53b8bdb..b2f48cf 100644 --- a/src/kairos/tui/state.py +++ b/src/kairos/tui/state.py @@ -11,6 +11,7 @@ from kairos.schemas.activity import ActivityEvent from kairos.schemas.artifact import ArtifactDetail, ArtifactSummary +from kairos.schemas.bookmark import BookmarkResult from kairos.schemas.config import ConfigSymbolResult from kairos.schemas.dashboard import DashboardResult from kairos.schemas.doctor import DoctorReport @@ -50,6 +51,7 @@ # docs/tli-implementation-plan.md's service-layer-gaps section for the # other documented deviations from the pasted spec's literal dataclass. "notes", + "bookmarks", ] FocusTarget = Literal["explorer", "workspace", "evidence", "command_line"] @@ -70,6 +72,7 @@ | WellDetail | list[NoteResult] | list[ActivityEvent] + | list[BookmarkResult] | None ) @@ -122,6 +125,10 @@ class TuiState: artifact_count: int = 0 workspace_size_bytes: int = 0 well_count: int = 0 + # Up to 3 most-recently-saved bookmarks, oldest first — for the tab bar. + # Loaded at startup and refreshed by :bookmark/:bookmarks so it's never + # more than one dispatch stale. + recent_bookmarks: tuple[BookmarkResult, ...] = () def as_list_of[T](value: object, item_type: type[T]) -> list[T] | None: diff --git a/src/kairos/tui/styles/kairos.tcss b/src/kairos/tui/styles/kairos.tcss index f5c8e25..90a4f46 100644 --- a/src/kairos/tui/styles/kairos.tcss +++ b/src/kairos/tui/styles/kairos.tcss @@ -196,6 +196,34 @@ WellPickerScreen { border-left: thick #f0883e; } +/* ── Bookmark picker overlay ───────────────────────────────────────── */ + +BookmarkPickerScreen { + align: center middle; +} + +#bookmark-picker-container { + background: #161b22; + border: heavy #d2a8ff; + width: 70%; + height: 70%; + padding: 1 2; +} + +#bookmark-picker-list { + height: 1fr; +} + +#bookmark-picker-list > ListItem { + padding: 0 1; + border-bottom: solid #21262d; +} + +#bookmark-picker-list > ListItem.-highlight { + background: #1c2128; + border-left: thick #d2a8ff; +} + /* ── Fuzzy finder overlay ──────────────────────────────────────────── */ FuzzyFinderScreen { @@ -277,6 +305,11 @@ GotoLineScreen { color: #c9d1d9; } +.tab-item.bookmark { + color: #d2a8ff; + text-style: none; +} + /* ── Tutorial overlay ──────────────────────────────────────────────── */ TutorialScreen { diff --git a/src/kairos/tui/widgets/tab_bar.py b/src/kairos/tui/widgets/tab_bar.py index c51e651..1830f4d 100644 --- a/src/kairos/tui/widgets/tab_bar.py +++ b/src/kairos/tui/widgets/tab_bar.py @@ -21,12 +21,17 @@ "history": "\u25b8 History", "help": "? Help", "notes": "\u270e Notes", + "bookmarks": "\u2605 Bookmarks", } +_BOOKMARK_GLYPH = "\u2605" + class _TabItem(Static): - def __init__(self, label: str, mode: str, active: bool = False) -> None: - super().__init__(label, classes="tab-item active" if active else "tab-item") + def __init__( + self, label: str, mode: str, active: bool = False, classes: str = "tab-item" + ) -> None: + super().__init__(label, classes=f"{classes} active" if active else classes) self.mode = mode @@ -41,3 +46,12 @@ def refresh_from_state(self, state: TuiState) -> None: self.remove_children() label = _MODE_LABELS.get(state.mode, state.mode) self.mount(_TabItem(label, state.mode, active=True)) + # Most-recently-saved first, matching the bookmark picker's ordering. + for bookmark in reversed(state.recent_bookmarks): + self.mount( + _TabItem( + f"{_BOOKMARK_GLYPH} {bookmark.name}", + "bookmarks", + classes="tab-item bookmark", + ) + ) diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 9b8369e..3990b96 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -103,6 +103,29 @@ async def test_selecting_artifact_renders_full_citation(runtime_ctx: RuntimeCont assert "parser:" in evidence +@pytest.mark.asyncio +async def test_selecting_in_explorer_does_not_duplicate_workspace_transcript( + runtime_ctx: RuntimeContext, +) -> None: + app = KairosApp(runtime_ctx) + async with app.run_test(size=WIDE) as pilot: + await _type_command(pilot, ":artifacts") + workspace_pane = app.query_one(WorkspacePane) + line_count = len(workspace_pane.lines) + + explorer = app.query_one(ExplorerPane) + explorer.focus() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("down") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert len(workspace_pane.lines) == line_count + + @pytest.mark.asyncio async def test_search_shows_hits_with_full_citations(runtime_ctx: RuntimeContext) -> None: app = KairosApp(runtime_ctx) diff --git a/tests/tui/test_bookmarks.py b/tests/tui/test_bookmarks.py new file mode 100644 index 0000000..b56c584 --- /dev/null +++ b/tests/tui/test_bookmarks.py @@ -0,0 +1,255 @@ +"""Bookmarks: JSON persistence roundtrip (services/bookmarks.py), the +``:bookmark``/``:bookmarks`` command grammar, and the Shift+B quick-access +picker end to end. +""" + +from __future__ import annotations + +import pytest + +from kairos.domain.errors import BookmarkNotFoundError, KairosError +from kairos.schemas.bookmark import BookmarkResult +from kairos.services.bookmarks import ( + bookmarks_file_path, + list_bookmarks, + remove_bookmark, + save_bookmark, +) +from kairos.services.context import RuntimeContext +from kairos.tui.controller import dispatch_text +from kairos.tui.state import TuiState, as_list_of + + +def _fresh_state(runtime_ctx: RuntimeContext) -> TuiState: + return TuiState(workspace_path=runtime_ctx.workspace.root) + + +# ── services/bookmarks.py — pure persistence ──────────────────────────── + + +def test_bookmarks_roundtrip_save_and_list(runtime_ctx: RuntimeContext) -> None: + assert list_bookmarks(runtime_ctx) == [] + + save_bookmark(runtime_ctx, "widgets", ":search widget") + save_bookmark(runtime_ctx, "wells", ":well list") + + bookmarks = list_bookmarks(runtime_ctx) + assert [b.name for b in bookmarks] == ["widgets", "wells"] + assert [b.command for b in bookmarks] == [":search widget", ":well list"] + assert bookmarks_file_path(runtime_ctx.workspace.root).exists() + + +def test_saving_an_existing_name_overwrites_and_moves_to_end(runtime_ctx: RuntimeContext) -> None: + save_bookmark(runtime_ctx, "widgets", ":search widget") + save_bookmark(runtime_ctx, "wells", ":well list") + save_bookmark(runtime_ctx, "widgets", ":search widgets --limit 5") + + bookmarks = list_bookmarks(runtime_ctx) + assert [b.name for b in bookmarks] == ["wells", "widgets"] + assert bookmarks[-1].command == ":search widgets --limit 5" + + +def test_remove_bookmark(runtime_ctx: RuntimeContext) -> None: + save_bookmark(runtime_ctx, "widgets", ":search widget") + remove_bookmark(runtime_ctx, "widgets") + assert list_bookmarks(runtime_ctx) == [] + + +def test_remove_unknown_bookmark_is_actionable_error(runtime_ctx: RuntimeContext) -> None: + with pytest.raises(BookmarkNotFoundError): + remove_bookmark(runtime_ctx, "nope") + + +def test_list_bookmarks_on_missing_file_is_empty(runtime_ctx: RuntimeContext) -> None: + assert list_bookmarks(runtime_ctx) == [] + + +def test_list_bookmarks_raises_actionable_error_on_corrupt_file( + runtime_ctx: RuntimeContext, +) -> None: + bookmarks_file_path(runtime_ctx.workspace.root).write_text("not json", encoding="utf-8") + with pytest.raises(KairosError): + list_bookmarks(runtime_ctx) + + +# ── controller: :bookmark / :bookmarks command grammar ────────────────── + + +def test_bookmark_command_saves_the_last_successful_command(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":search widget") + state = dispatch_text(runtime_ctx, state, ":bookmark widgets") + + assert state.mode == "bookmarks" + assert state.status == "idle" + bookmarks = as_list_of(state.last_result, BookmarkResult) + assert bookmarks is not None + assert bookmarks[0].name == "widgets" + assert bookmarks[0].command == ":search widget" + assert state.recent_bookmarks[-1].name == "widgets" + + +def test_bookmark_with_nothing_run_yet_is_actionable_error(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":bookmark widgets") + assert state.status == "error" + assert "Nothing to bookmark" in (state.status_message or "") + + +def test_bookmark_without_a_name_is_a_usage_error(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":search widget") + state = dispatch_text(runtime_ctx, state, ":bookmark") + assert state.status == "error" + assert "Usage" in (state.status_message or "") + + +def test_bookmarks_command_lists_saved_bookmarks(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":search widget") + state = dispatch_text(runtime_ctx, state, ":bookmark widgets") + state = dispatch_text(runtime_ctx, state, ":artifacts") + state = dispatch_text(runtime_ctx, state, ":bookmark all-artifacts") + + state = dispatch_text(runtime_ctx, state, ":bookmarks") + assert state.mode == "bookmarks" + assert "2 bookmark" in (state.status_message or "") + names = [b.name for b in state.recent_bookmarks] + assert names == ["widgets", "all-artifacts"] + + +def test_bookmark_remove_via_command(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":search widget") + state = dispatch_text(runtime_ctx, state, ":bookmark widgets") + state = dispatch_text(runtime_ctx, state, ":bookmark --remove widgets") + + assert state.status == "idle" + assert list_bookmarks(runtime_ctx) == [] + assert state.recent_bookmarks == () + + +def test_bookmark_remove_unknown_name_is_actionable_error(runtime_ctx: RuntimeContext) -> None: + state = dispatch_text(runtime_ctx, _fresh_state(runtime_ctx), ":bookmark --remove nope") + assert state.status == "error" + assert "nope" in (state.status_message or "") + + +def test_recent_bookmarks_caps_at_three(runtime_ctx: RuntimeContext) -> None: + state = _fresh_state(runtime_ctx) + for name in ("a", "b", "c", "d"): + state = dispatch_text(runtime_ctx, state, ":search widget") + state = dispatch_text(runtime_ctx, state, f":bookmark {name}") + + assert [b.name for b in state.recent_bookmarks] == ["b", "c", "d"] + assert [b.name for b in list_bookmarks(runtime_ctx)] == ["a", "b", "c", "d"] + + +# ── Pilot-driven: Shift+B picker, tab bar ──────────────────────────────── + +pytest.importorskip("textual") +pytest.importorskip("pytest_asyncio") + +from kairos.schemas.search import SearchResult # noqa: E402 +from kairos.tui.app import KairosApp # noqa: E402 +from kairos.tui.screens.bookmark_picker import BookmarkPickerScreen # noqa: E402 +from kairos.tui.widgets.explorer_pane import ExplorerPane # noqa: E402 +from kairos.tui.widgets.tab_bar import TabBar # noqa: E402 + +WIDE = (140, 30) + + +async def _type_command(pilot: object, text: str) -> None: + await pilot.click("#command-line") # type: ignore[attr-defined] + await pilot.press(*text) # type: ignore[attr-defined] + await pilot.press("enter") # type: ignore[attr-defined] + await pilot.pause() # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_shift_b_opens_picker_and_running_a_bookmark_reruns_its_command( + runtime_ctx: RuntimeContext, +) -> None: + app = KairosApp(runtime_ctx) + async with app.run_test(size=WIDE) as pilot: + await _type_command(pilot, ":search widget") + await _type_command(pilot, ":bookmark widgets") + await _type_command(pilot, ":artifacts") + assert app.state.mode == "artifacts" + + app.query_one(ExplorerPane).focus() # command-line still holds focus otherwise + await pilot.pause() + await pilot.press("B") + await pilot.pause() + assert isinstance(app.screen, BookmarkPickerScreen) + + await pilot.press("enter") + await pilot.pause() + + assert not isinstance(app.screen, BookmarkPickerScreen) + assert app.state.mode == "search" + assert isinstance(app.state.last_result, SearchResult) + + +@pytest.mark.asyncio +async def test_bookmark_picker_escape_cancels_without_running_anything( + runtime_ctx: RuntimeContext, +) -> None: + app = KairosApp(runtime_ctx) + async with app.run_test(size=WIDE) as pilot: + await _type_command(pilot, ":search widget") + await _type_command(pilot, ":bookmark widgets") + await _type_command(pilot, ":artifacts") + + app.query_one(ExplorerPane).focus() + await pilot.pause() + await pilot.press("B") + await pilot.pause() + assert isinstance(app.screen, BookmarkPickerScreen) + + await pilot.press("escape") + await pilot.pause() + + assert not isinstance(app.screen, BookmarkPickerScreen) + assert app.state.mode == "artifacts" + + +@pytest.mark.asyncio +async def test_bookmark_picker_d_removes_and_syncs_tab_bar(runtime_ctx: RuntimeContext) -> None: + app = KairosApp(runtime_ctx) + async with app.run_test(size=WIDE) as pilot: + await _type_command(pilot, ":search widget") + await _type_command(pilot, ":bookmark widgets") + assert any( + t.mode == "bookmarks" # type: ignore[attr-defined] + for t in app.query_one(TabBar).children[1:] + ) + + app.query_one(ExplorerPane).focus() + await pilot.pause() + await pilot.press("B") + await pilot.pause() + assert isinstance(app.screen, BookmarkPickerScreen) + await pilot.press("d") + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert list_bookmarks(runtime_ctx) == [] + assert app.state.recent_bookmarks == () + assert len(list(app.query_one(TabBar).children)) == 1 + + +@pytest.mark.asyncio +async def test_tab_bar_shows_up_to_three_most_recent_bookmarks( + runtime_ctx: RuntimeContext, +) -> None: + app = KairosApp(runtime_ctx) + async with app.run_test(size=WIDE) as pilot: + for name in ("a", "b", "c", "d"): + await _type_command(pilot, ":search widget") + await _type_command(pilot, f":bookmark {name}") + + tab_labels = [ + str(t.renderable) # type: ignore[attr-defined] + for t in app.query_one(TabBar).children + ] + assert "★ d" in tab_labels + assert "★ c" in tab_labels + assert "★ b" in tab_labels + assert "★ a" not in tab_labels