Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions docs/tli.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,29 +87,56 @@ Responsive to terminal width:
:doctor workspace health checks (inspect only)
:note list <target-id> notes on an artifact or span
:note add <target-id> <text> 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 <name> save the last command as a named bookmark
:bookmark --remove <name> 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 <name>` 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 |
Expand Down
4 changes: 4 additions & 0 deletions src/kairos/domain/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
13 changes: 13 additions & 0 deletions src/kairos/schemas/bookmark.py
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions src/kairos/services/bookmarks.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 47 additions & 2 deletions src/kairos/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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),
Expand All @@ -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()
Expand Down Expand Up @@ -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 --------------------------------------------------------------

Expand Down Expand Up @@ -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())

Expand Down
4 changes: 4 additions & 0 deletions src/kairos/tui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"note",
"ingest",
"tutorial",
"bookmark",
"bookmarks",
}
)

Expand Down Expand Up @@ -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 <name> (--remove <name> to delete)",
"bookmarks": "list saved bookmarks (Shift+B to pick and re-run one)",
}


Expand Down
63 changes: 63 additions & 0 deletions src/kairos/tui/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +57,8 @@
"history": "history",
"help": "help",
"note": "notes",
"bookmark": "bookmarks",
"bookmarks": "bookmarks",
}


Expand Down Expand Up @@ -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 <name> | :bookmark --remove <name>")

if command.args[0] == "--remove":
if len(command.args) < 2:
raise KairosError("Usage: :bookmark --remove <name>")
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,
Expand All @@ -489,4 +550,6 @@ def _ingest(runtime_ctx: RuntimeContext, state: TuiState, command: Command) -> T
"well": _well,
"note": _note,
"ingest": _ingest,
"bookmark": _bookmark,
"bookmarks": _bookmarks,
}
Loading
Loading