From 4eebf48ff01abe270e4c65724009b99de13b0393 Mon Sep 17 00:00:00 2001 From: Simone Celestino Date: Fri, 15 May 2026 21:52:34 +0200 Subject: [PATCH 01/13] feat: Initial commit introducing TUI --- tests/test_tui_fuzzy.py | 43 +++ tests/test_tui_palette.py | 58 ++++ tui/README.md | 165 ++++++++++ tui/__init__.py | 0 tui/__main__.py | 27 ++ tui/api.py | 260 +++++++++++++++ tui/app.py | 147 +++++++++ tui/clipboard.py | 50 +++ tui/commands.py | 258 +++++++++++++++ tui/config.py | 45 +++ tui/fuzzy.py | 65 ++++ tui/leader.py | 100 ++++++ tui/palette.py | 591 +++++++++++++++++++++++++++++++++++ tui/playback.py | 98 ++++++ tui/requirements.txt | 6 + tui/screens/__init__.py | 0 tui/screens/jobs.py | 84 +++++ tui/screens/library.py | 259 +++++++++++++++ tui/screens/settings.py | 107 +++++++ tui/screens/transcript.py | 204 ++++++++++++ tui/screens/welcome.py | 141 +++++++++ tui/server.py | 138 ++++++++ tui/sse.py | 28 ++ tui/waveform.py | 174 +++++++++++ tui/widgets/__init__.py | 0 tui/widgets/command_input.py | 21 ++ tui/widgets/progress_bar.py | 21 ++ tui/widgets/segment_list.py | 87 ++++++ tui/widgets/status_bar.py | 56 ++++ tui/widgets/waveform_view.py | 60 ++++ 30 files changed, 3293 insertions(+) create mode 100644 tests/test_tui_fuzzy.py create mode 100644 tests/test_tui_palette.py create mode 100644 tui/README.md create mode 100644 tui/__init__.py create mode 100644 tui/__main__.py create mode 100644 tui/api.py create mode 100644 tui/app.py create mode 100644 tui/clipboard.py create mode 100644 tui/commands.py create mode 100644 tui/config.py create mode 100644 tui/fuzzy.py create mode 100644 tui/leader.py create mode 100644 tui/palette.py create mode 100644 tui/playback.py create mode 100644 tui/requirements.txt create mode 100644 tui/screens/__init__.py create mode 100644 tui/screens/jobs.py create mode 100644 tui/screens/library.py create mode 100644 tui/screens/settings.py create mode 100644 tui/screens/transcript.py create mode 100644 tui/screens/welcome.py create mode 100644 tui/server.py create mode 100644 tui/sse.py create mode 100644 tui/waveform.py create mode 100644 tui/widgets/__init__.py create mode 100644 tui/widgets/command_input.py create mode 100644 tui/widgets/progress_bar.py create mode 100644 tui/widgets/segment_list.py create mode 100644 tui/widgets/status_bar.py create mode 100644 tui/widgets/waveform_view.py diff --git a/tests/test_tui_fuzzy.py b/tests/test_tui_fuzzy.py new file mode 100644 index 0000000..d2c5203 --- /dev/null +++ b/tests/test_tui_fuzzy.py @@ -0,0 +1,43 @@ +"""Tests for the TUI palette fuzzy matcher.""" +from __future__ import annotations + +from tui.fuzzy import rank, score_match + + +def test_exact_prefix_outranks_substring(): + assert score_match("lib", "library") > score_match("lib", "available libs") + + +def test_no_match_returns_none(): + assert score_match("xyz", "library") is None + + +def test_consecutive_chars_boosted(): + """Consecutive subsequence beats sparse non-boundary subsequence.""" + consecutive = score_match("abc", "abcdef") + spread = score_match("abc", "axxxbxxxcxxx") + assert consecutive is not None and spread is not None + assert consecutive > spread + + +def test_word_boundary_boost(): + """Acronym-style matches across word boundaries rank well.""" + boundary = score_match("abc", "a_b_c_d") + nonboundary = score_match("abc", "azzbzzczz") + assert boundary is not None and nonboundary is not None + assert boundary > nonboundary + + +def test_empty_query_preserves_order(): + items = ["one", "two", "three"] + out = rank("", items) + assert [it for _s, it in out] == items + + +def test_rank_sorts_desc(): + items = ["report.md", "rapid.md", "readme.md"] + out = rank("rea", items) + assert out[0][1] == "readme.md" + # Non-subsequence matches dropped. + out2 = rank("zzz", items) + assert out2 == [] diff --git a/tests/test_tui_palette.py b/tests/test_tui_palette.py new file mode 100644 index 0000000..7748b70 --- /dev/null +++ b/tests/test_tui_palette.py @@ -0,0 +1,58 @@ +"""Regression tests for the palette entry transforms. + +Backend Tag/Folder/Recording IDs are UUID strings; a prior version cast +them to int and crashed. These tests pin the pure transforms so the +crash can't return, and cover the LLM-model normalisation that accepts +multiple response shapes. +""" +from __future__ import annotations + +from tui.palette import ( + entries_from_folders, + entries_from_models, + entries_from_tags, +) + + +def test_entries_from_folders_uuid_ids(): + out = entries_from_folders([ + {"id": "2e9c6cc2-e08c-459e-917c-0d0a4d634322", "name": "ideas"}, + {"id": "abcd-efgh", "name": "work"}, + ]) + assert len(out) == 2 + assert out[0].key == "folder:2e9c6cc2-e08c-459e-917c-0d0a4d634322" + assert out[0].display.endswith("ideas") + + +def test_entries_from_tags_uuid_ids(): + out = entries_from_tags([ + {"id": "u-1234", "name": "meeting"}, + {"id": "u-5678", "name": "podcast"}, + ]) + assert {e.display for e in out} == {"# meeting", "# podcast"} + + +def test_entries_from_tags_skips_missing_id(): + out = entries_from_tags([{"name": "no-id"}, {"id": "x", "name": "ok"}]) + assert len(out) == 1 + assert out[0].display == "# ok" + + +def test_entries_from_models_mixed_shapes(): + out = entries_from_models({"models": [ + {"name": "llama3.1"}, + {"model": "qwen2.5"}, + "mistral", + ]}) + names = {e.key.split(":", 1)[1] for e in out} + assert names == {"llama3.1", "qwen2.5", "mistral"} + + +def test_entries_from_models_handles_bare_list(): + out = entries_from_models(["llama3.1", "qwen2.5"]) + assert {e.display for e in out} == {"⚡ llama3.1", "⚡ qwen2.5"} + + +def test_entries_from_models_empty(): + assert entries_from_models({}) == [] + assert entries_from_models(None) == [] diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..d4ecb4e --- /dev/null +++ b/tui/README.md @@ -0,0 +1,165 @@ +# AmicoScript TUI + +Terminal interface for AmicoScript. Wraps the FastAPI backend over HTTP/SSE. + +## Install + +```bash +pip install -r tui/requirements.txt +``` + +## Run + +```bash +python -m tui # spawn local server, attach +python -m tui --no-server # attach to already-running server +python -m tui --api-url http://host:8002 # remote server +``` + +The TUI launches `run.py` as a subprocess if no backend responds at the API +URL. The subprocess inherits `AMICOSCRIPT_NO_BROWSER=1` so it does not pop a +browser window. On exit (Ctrl+C / `q` / `/quit`) the subprocess is terminated. + +## Keys + +The TUI is **modeless and palette-driven** — no tabs. Press `Space` to arm +the leader; the status bar lists the available chords. Press `/` or +`Ctrl+K` to open the unified fuzzy palette (commands + recordings + jobs). + +### Leader chords (Space + …) + +Available on the welcome screen and every full-screen view; the set +varies per screen and is shown in the status bar when armed. + +| Chord | Action | +|-------|--------| +| `Space l` | Library | +| `Space j` | Jobs | +| `Space s` | Settings | +| `Space h` | Back to welcome | +| `Space ?` | Help | +| `Space q` | Quit | + +On the welcome screen, bare `l` / `j` / `s` also jump directly. + +### Palette + +| Key | Action | +|-----|--------| +| `/` | Open palette pre-seeded with `/` (commands) | +| `@` | Open palette pre-seeded with `@` (transcripts) | +| `Ctrl+K` | Open palette empty (free fuzzy) | +| `Ctrl+P` | Open palette pre-seeded with `/` (commands) | +| `Tab` | Auto-complete the current command name; cycles if no match | +| `↑` / `↓` / `Shift+Tab` | Move selection | +| `Enter` | Activate highlighted entry | +| `Escape` | Close | + +Free-text fuzzy matching ranks commands, recordings, and active jobs in +one list. Leading `/` filters to commands only. Recent selections (MRU) +rank higher on subsequent opens. + +**Sub-pickers.** After auto-completing certain commands the palette +switches mode and filters a different source: + +| Trigger | Mode | Source | Enter action | +|---------|------|--------|--------------| +| `/library ` | library | recordings | open transcript | +| `/folder ` | folder | folders | open library scoped to folder | +| `/tag ` | tag | tags | open library scoped to tag | +| `/analyze ` | analyze | recordings | choose analysis type, then queue | +| `/models ` | model | LLM models | set as default model | +| `@` | transcript | recordings | open transcript | + +### Library +| Key | Action | +|-----|--------| +| `↑↓` / `j` `k` | Move row | +| `g g` / `G` | Top / bottom | +| `Enter` | Open recording (transcript screen) | +| `r` | Refresh library | +| `y` | Copy filename to clipboard | +| `d` | Delete (prompt) | +| `Escape` | Back | + +### Transcript +| Key | Action | +|-----|--------| +| `j` / `k` | Move segment | +| `g g` / `G` | First / last segment | +| `n` / `N` | Next / previous speaker change | +| `y` | Copy current segment | +| `Y` | Copy full transcript | +| `Space` | Play / pause | +| `s` | Stop | +| `Ctrl+A` | Run LLM analysis on this recording | +| `Escape` / `q` | Back to library | + +### Job screen +| Key | Action | +|-----|--------| +| `c` | Cancel job | +| `Escape` / `q` | Back | + +### Global +| Key | Action | +|-----|--------| +| `Space` | Arm leader chord | +| `/` · `Ctrl+K` | Open palette | +| `Ctrl+C` | Quit | + +## Slash Commands + +Press `/` to open the command palette. + +| Command | Action | +|---------|--------| +| `/help` | Show command reference | +| `/transcribe ` | Upload file and transcribe | +| `/transcribe-url ` | Download from URL and transcribe | +| `/search ` | Full-text search across transcripts | +| `/export ` | Export transcript (json/srt/txt/md) — saved to CWD | +| `/cancel ` | Cancel running job | +| `/delete ` | Delete recording | +| `/folder new ` | Create folder | +| `/library` | Open the recordings library (sub-picker after space) | +| `/folder` | Pick a folder (or `/folder new ` to create) | +| `/tag` | Pick a tag | +| `/analyze` | Pick a recording and run summary / action_items / translate / custom | +| `/models` | Pick an LLM model (sets as default) | +| `/llm` | Open LLM settings | +| `/jobs` | Open the active-jobs list | +| `/welcome` | Return to the welcome screen | +| `/settings` | Open settings screen | +| `/logs` | Show captured server logs | +| `/quit` | Exit | + +## Drag & Drop + +Drag an audio/video file onto the terminal window — modern terminals +(iTerm2, WezTerm, Kitty, Windows Terminal, GNOME Terminal) emit the path +as a paste event. The TUI intercepts paths with audio extensions and +auto-triggers `/transcribe`. + +Inside tmux you may need `set -g allow-passthrough on` for OSC 52 +clipboard writes to reach the host clipboard. + +## Clipboard + +Copy operations (`y`, `Y`) try `pyperclip` first, then fall back to +OSC 52 escape sequences — so clipboard works over SSH. + +## Waveform + +Transcript screen renders a single-line unicode waveform of the audio +using block characters `▁▂▃▄▅▆▇█`. Audio is fetched from +`/api/recordings/{id}/audio` to a temp file, downsampled via numpy + +soundfile, and discarded on screen exit. + +## Limitations (v1) + +- No segment editing (view + copy only) +- No audio playback +- No multi-select copy across segments (single segment via `y`, full + transcript via `Y`) +- Folder/tag pickers not yet wired into library filtering UI diff --git a/tui/__init__.py b/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/__main__.py b/tui/__main__.py new file mode 100644 index 0000000..54966b5 --- /dev/null +++ b/tui/__main__.py @@ -0,0 +1,27 @@ +"""Entry point: `python -m tui`.""" +from __future__ import annotations + +import sys + + +def main() -> int: + from .config import parse_args + from .app import AmicoTUI + from .server import ServerManager + + cfg = parse_args(sys.argv[1:]) + server = ServerManager(cfg.api_url, spawn=cfg.spawn_server) + + try: + if not server.ensure_ready(): + print("Failed to reach backend; aborting.", file=sys.stderr) + return 1 + app = AmicoTUI(cfg=cfg, server=server) + app.run() + return 0 + finally: + server.shutdown() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tui/api.py b/tui/api.py new file mode 100644 index 0000000..b2ef88a --- /dev/null +++ b/tui/api.py @@ -0,0 +1,260 @@ +"""Async HTTP client wrapping the AmicoScript backend. + +Methods mirror the REST endpoints in backend/main.py. All return raw +dicts/lists decoded from JSON. Errors raise httpx.HTTPStatusError. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Iterable + +import httpx + + +# Generous timeout — uploads of large audio files can take minutes; the +# server may also take time to load whisper models on first request. +DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=5.0, read=600.0) + + +class ApiClient: + """Thin async wrapper around the backend REST API.""" + + def __init__(self, base_url: str, timeout: httpx.Timeout | None = None): + self.base_url = base_url.rstrip("/") + self.client = httpx.AsyncClient( + base_url=self.base_url, timeout=timeout or DEFAULT_TIMEOUT + ) + + async def aclose(self) -> None: + await self.client.aclose() + + # --- generic helpers -------------------------------------------- + + async def _get(self, path: str, **params: Any) -> Any: + r = await self.client.get(path, params=_drop_none(params)) + r.raise_for_status() + return r.json() + + async def _post(self, path: str, json: Any = None) -> Any: + r = await self.client.post(path, json=json) + r.raise_for_status() + return r.json() if r.content else {} + + async def _patch(self, path: str, json: Any = None) -> Any: + r = await self.client.patch(path, json=json) + r.raise_for_status() + return r.json() if r.content else {} + + async def _delete(self, path: str, **params: Any) -> Any: + r = await self.client.delete(path, params=_drop_none(params)) + r.raise_for_status() + return r.json() if r.content else {} + + # --- version / health ------------------------------------------- + + async def version(self) -> dict: + return await self._get("/api/version") + + async def models(self) -> dict: + return await self._get("/api/models") + + async def latest_release(self) -> dict: + return await self._get("/api/latest-release") + + # --- library / recordings --------------------------------------- + + async def library( + self, + folder_id: str | None = None, + tag_id: str | None = None, + status: str | None = None, + sort: str = "created_at", + order: str = "desc", + limit: int = 50, + offset: int = 0, + ) -> dict: + return await self._get( + "/api/library", + folder_id=folder_id, + tag_id=tag_id, + status=status, + sort=sort, + order=order, + limit=limit, + offset=offset, + ) + + async def recording(self, recording_id: str) -> dict: + return await self._get(f"/api/recordings/{recording_id}") + + async def transcript(self, recording_id: str) -> dict: + return await self._get(f"/api/recordings/{recording_id}/transcript") + + async def update_recording(self, recording_id: str, **fields: Any) -> dict: + return await self._patch(f"/api/recordings/{recording_id}", json=fields) + + async def delete_recording(self, recording_id: str) -> dict: + return await self._delete(f"/api/recordings/{recording_id}") + + async def export( + self, recording_id: str, fmt: str + ) -> tuple[bytes, str | None]: + """Return (body, filename) for a transcript export.""" + r = await self.client.get( + f"/api/recordings/{recording_id}/export/{fmt}" + ) + r.raise_for_status() + filename = _filename_from_disposition( + r.headers.get("content-disposition") + ) + return r.content, filename + + # --- folders / tags / search ------------------------------------ + + async def folders(self) -> list[dict]: + return await self._get("/api/folders") + + async def create_folder( + self, name: str, parent_id: int | None = None, color_code: str | None = None + ) -> dict: + return await self._post( + "/api/folders", + json=_drop_none( + {"name": name, "parent_id": parent_id, "color_code": color_code} + ), + ) + + async def update_folder(self, folder_id: int, **fields: Any) -> dict: + return await self._patch(f"/api/folders/{folder_id}", json=fields) + + async def delete_folder( + self, folder_id: int, delete_recordings: bool = False + ) -> dict: + return await self._delete( + f"/api/folders/{folder_id}", delete_recordings=delete_recordings + ) + + async def tags(self, folder_id: int | None = None) -> list[dict]: + return await self._get("/api/tags", folder_id=folder_id) + + async def create_tag(self, name: str, color_code: str | None = None) -> dict: + return await self._post( + "/api/tags", json=_drop_none({"name": name, "color_code": color_code}) + ) + + async def add_tag(self, recording_id: str, tag_id: int) -> dict: + return await self._post( + f"/api/recordings/{recording_id}/tags/{tag_id}" + ) + + async def remove_tag(self, recording_id: str, tag_id: int) -> dict: + return await self._delete( + f"/api/recordings/{recording_id}/tags/{tag_id}" + ) + + async def search(self, q: str, limit: int = 50, offset: int = 0) -> dict: + return await self._get("/api/search", q=q, limit=limit, offset=offset) + + # --- jobs -------------------------------------------------------- + + async def job_result(self, job_id: str) -> dict: + return await self._get(f"/api/jobs/{job_id}/result") + + async def job_logs(self, job_id: str, limit: int = 200) -> dict: + return await self._get(f"/api/jobs/{job_id}/logs", limit=limit) + + async def cancel_job(self, job_id: str) -> dict: + return await self._post(f"/api/jobs/{job_id}/cancel") + + # --- transcribe -------------------------------------------------- + + async def transcribe_url(self, url: str, **options: Any) -> dict: + payload = {"url": url, **_drop_none(options)} + return await self._post("/api/transcribe/url", json=payload) + + async def transcribe_file( + self, + path: Path, + options: dict[str, Any] | None = None, + on_progress: Callable[[int, int], None] | None = None, + ) -> dict: + """Upload a file to /api/transcribe with optional progress callback. + + on_progress(bytes_sent, total_bytes) is invoked as the file streams. + """ + path = Path(path) + total = path.stat().st_size + sent = 0 + + def reader() -> Iterable[bytes]: + nonlocal sent + with path.open("rb") as f: + while True: + chunk = f.read(64 * 1024) + if not chunk: + break + sent += len(chunk) + if on_progress is not None: + on_progress(sent, total) + yield chunk + + files = {"file": (path.name, reader(), "application/octet-stream")} + data = {k: str(v) for k, v in (options or {}).items() if v is not None} + r = await self.client.post("/api/transcribe", data=data, files=files) + r.raise_for_status() + return r.json() + + # --- analyses / llm --------------------------------------------- + + async def analyses(self, recording_id: str) -> list[dict]: + return await self._get(f"/api/recordings/{recording_id}/analyses") + + async def create_analysis( + self, recording_id: str, analysis_type: str, **opts: Any + ) -> dict: + return await self._post( + f"/api/recordings/{recording_id}/analyses", + json={"analysis_type": analysis_type, **_drop_none(opts)}, + ) + + async def llm_settings(self) -> dict: + return await self._get("/api/llm/settings") + + async def save_llm_settings(self, **fields: Any) -> dict: + return await self._post("/api/llm/settings", json=_drop_none(fields)) + + async def llm_test_connection(self) -> dict: + return await self._post("/api/llm/test-connection") + + async def llm_models(self) -> dict: + return await self._get("/api/llm/models") + + async def llm_pull_model(self, name: str) -> dict: + return await self._post("/api/llm/models/pull", json={"name": name}) + + # --- settings --------------------------------------------------- + + async def settings(self) -> dict: + return await self._get("/api/settings") + + async def save_settings(self, hf_token: str | None = None) -> dict: + return await self._post( + "/api/settings", json=_drop_none({"hf_token": hf_token}) + ) + + +# --- helpers -------------------------------------------------------- + + +def _drop_none(d: dict) -> dict: + return {k: v for k, v in d.items() if v is not None} + + +def _filename_from_disposition(header: str | None) -> str | None: + if not header: + return None + for part in header.split(";"): + part = part.strip() + if part.lower().startswith("filename="): + return part.split("=", 1)[1].strip().strip('"') + return None diff --git a/tui/app.py b/tui/app.py new file mode 100644 index 0000000..de8e727 --- /dev/null +++ b/tui/app.py @@ -0,0 +1,147 @@ +"""Main Textual App for AmicoScript TUI. + +Modeless, palette-driven. No tabs. Leader key (Space) arms per-screen +chord maps; ``/`` or ``ctrl+k`` opens the unified fuzzy palette. +""" +from __future__ import annotations + +from collections import deque + +from textual.app import App +from textual.binding import Binding +from textual.events import Key + +from .api import ApiClient +from .commands import run_command +from .config import Config +from .leader import LeaderDispatcher +from .palette import Palette +from .screens.welcome import WelcomeScreen +from .server import ServerManager + + +class AmicoTUI(App): + """AmicoScript terminal interface.""" + + CSS = """ + $primary: #6c63ff; + $accent: #a78bfa; + $surface: #0f172a; + $panel: #1e293b; + $boost: #2a3548; + $text: #e2e8f0; + $text-muted: #94a3b8; + $success: #10b981; + $warning: #f59e0b; + $error: #ef4444; + + Screen { background: $surface; color: $text; } + Header { background: $primary; color: $text; } + DataTable > .datatable--header { + background: $panel; + color: $accent; + } + DataTable > .datatable--cursor { + background: $primary; + color: $text; + } + DataTable > .datatable--hover { + background: $boost; + } + OptionList { background: $surface; color: $text; } + OptionList > .option-list--option-highlighted { + background: $primary; + color: $text; + } + Input { + background: $panel; + color: $text; + border: tall $primary; + } + Button { + background: $primary; + color: $text; + } + """ + + BINDINGS = [ + Binding("ctrl+c", "quit", "Quit", priority=True, show=False), + Binding("slash", "palette('/')", "Palette", priority=True, show=False), + Binding("at", "palette('@')", "Palette @", priority=True, show=False), + Binding("ctrl+k", "palette()", "Palette", priority=True, show=False), + Binding("ctrl+p", "palette('/')", "Commands", priority=True, show=False), + ] + + def __init__(self, cfg: Config, server: ServerManager) -> None: + super().__init__() + self.cfg = cfg + self.server = server + self.api = ApiClient(cfg.api_url) + self.title = "AmicoScript" + self.sub_title = cfg.api_url + self._palette_mru: deque = deque(maxlen=30) + self.leader = LeaderDispatcher(self) + + def on_mount(self) -> None: + self.push_screen(WelcomeScreen()) + self.run_worker(self._health_loop(), exclusive=True, name="health") + + async def on_unmount(self) -> None: + await self.api.aclose() + + async def _health_loop(self) -> None: + """Probe /api/version periodically; notify on transitions.""" + import asyncio + last_ok = True + backoff = 1.0 + while True: + try: + await self.api.version() + if not last_ok: + self.notify("backend reconnected") + last_ok = True + backoff = 1.0 + await asyncio.sleep(5.0) + except Exception: + if last_ok: + self.notify("backend disconnected · retrying", severity="warning") + last_ok = False + await asyncio.sleep(backoff) + backoff = min(30.0, backoff * 2) + + # --- key intercept (leader) ----------------------------------------- + + def on_key(self, event: Key) -> None: + if self.leader.handle_key(event): + event.stop() + event.prevent_default() + + # --- actions -------------------------------------------------------- + + def action_palette(self, seed: str = "") -> None: + # Avoid stacking duplicate palettes. + if isinstance(self.screen, Palette): + return + self.push_screen(Palette(initial=seed)) + + async def on_paste(self, event) -> None: + """Handle drag-and-drop: terminals emit dropped path as paste.""" + text = (event.text or "").strip().strip('"').strip("'") + if not text: + return + if text.startswith("file://"): + text = text[7:] + from pathlib import Path + p = Path(text) + if p.is_file() and p.suffix.lower() in { + ".mp3", ".wav", ".m4a", ".flac", ".ogg", ".opus", + ".mp4", ".mkv", ".webm", ".mov", ".aac", + }: + self.notify(f"dropped: {p.name} — transcribing") + await run_command(self, f"transcribe {shquote(str(p))}") + + +def shquote(s: str) -> str: + if " " in s or "'" in s: + return '"' + s.replace('"', '\\"') + '"' + return s diff --git a/tui/clipboard.py b/tui/clipboard.py new file mode 100644 index 0000000..32d19a8 --- /dev/null +++ b/tui/clipboard.py @@ -0,0 +1,50 @@ +"""Clipboard helpers: pyperclip primary, OSC 52 fallback for SSH/remote. + +OSC 52 is a terminal escape sequence supported by most modern terminals +(iTerm2, WezTerm, Kitty, Alacritty, recent xterm, Windows Terminal). It +writes to the system clipboard even when no local pyperclip backend is +available — useful when running over SSH. +""" +from __future__ import annotations + +import base64 +import os +import sys + + +OSC52_MAX_BYTES = 100_000 # most terminals cap around this + + +def copy_to_clipboard(text: str) -> bool: + """Copy text via pyperclip, fall back to OSC 52. Return True on success.""" + if not text: + return False + if _try_pyperclip(text): + return True + return _try_osc52(text) + + +def _try_pyperclip(text: str) -> bool: + try: + import pyperclip # type: ignore + pyperclip.copy(text) + return True + except Exception: + return False + + +def _try_osc52(text: str) -> bool: + payload = text.encode("utf-8") + if len(payload) > OSC52_MAX_BYTES: + payload = payload[:OSC52_MAX_BYTES] + b64 = base64.b64encode(payload).decode("ascii") + seq = f"\x1b]52;c;{b64}\x07" + # Inside tmux, escape sequences must be wrapped to pass through. + if os.environ.get("TMUX"): + seq = f"\x1bPtmux;\x1b{seq}\x1b\\" + try: + sys.stdout.write(seq) + sys.stdout.flush() + return True + except Exception: + return False diff --git a/tui/commands.py b/tui/commands.py new file mode 100644 index 0000000..ec7aa83 --- /dev/null +++ b/tui/commands.py @@ -0,0 +1,258 @@ +"""Slash command registry and handlers.""" +from __future__ import annotations + +import shlex +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Awaitable, Callable + +if TYPE_CHECKING: + from .app import AmicoTUI + + +@dataclass +class Command: + name: str + help: str + handler: Callable[["AmicoTUI", list[str]], Awaitable[None]] + + +COMMANDS: dict[str, Command] = {} + + +def command(name: str, help: str): + def decorator(fn): + COMMANDS[name] = Command(name, help, fn) + return fn + return decorator + + +def list_commands() -> list[Command]: + return sorted(COMMANDS.values(), key=lambda c: c.name) + + +async def run_command(app: "AmicoTUI", raw: str) -> None: + raw = raw.strip() + if not raw: + return + if raw.startswith("/"): + raw = raw[1:] + try: + parts = shlex.split(raw) + except ValueError as e: + app.notify(f"parse error: {e}") + return + if not parts: + return + cmd_name, *args = parts + cmd = COMMANDS.get(cmd_name) + if cmd is None: + app.notify(f"unknown command: /{cmd_name}") + return + try: + await cmd.handler(app, args) + except Exception as e: + app.notify(f"/{cmd_name} failed: {e}") + + +# --- handlers -------------------------------------------------------- + + +@command("help", "show command reference") +async def _help(app, args): + lines = [f"/{c.name} — {c.help}" for c in list_commands()] + app.notify("\n".join(lines), timeout=10) + + +@command("transcribe", "upload and transcribe") +async def _transcribe(app, args): + if not args: + app.notify("usage: /transcribe ") + return + path = Path(args[0]).expanduser() + if not path.is_file(): + app.notify(f"not a file: {path}") + return + result = await app.api.transcribe_file(path) + job_id = result.get("job_id") or result.get("id") + if job_id: + from .screens.jobs import JobScreen + app.push_screen(JobScreen(job_id)) + else: + app.notify(f"submitted: {result}") + + +@command("transcribe-url", "transcribe from ") +async def _transcribe_url(app, args): + if not args: + app.notify("usage: /transcribe-url ") + return + result = await app.api.transcribe_url(args[0]) + jobs = result.get("jobs") or ([result] if result.get("job_id") else []) + if jobs: + from .screens.jobs import JobScreen + app.push_screen(JobScreen(jobs[0].get("job_id") or jobs[0].get("id"))) + else: + app.notify(f"submitted: {result}") + + +@command("search", "full-text search ") +async def _search(app, args): + if not args: + app.notify("usage: /search ") + return + q = " ".join(args) + data = await app.api.search(q) + hits = data.get("results") or data.get("hits") or [] + if not hits: + app.notify("no results") + return + lines = [ + f"{h.get('recording_id', '')[:8]} {h.get('snippet') or h.get('text', '')[:60]}" + for h in hits[:20] + ] + app.notify("\n".join(lines), timeout=10) + + +@command("export", "export ") +async def _export(app, args): + if len(args) < 2: + app.notify("usage: /export ") + return + rec_id, fmt = args[0], args[1] + body, filename = await app.api.export(rec_id, fmt) + out = Path.cwd() / (filename or f"{rec_id}.{fmt}") + out.write_bytes(body) + app.notify(f"saved: {out}") + + +@command("cancel", "cancel job ") +async def _cancel(app, args): + if not args: + app.notify("usage: /cancel ") + return + await app.api.cancel_job(args[0]) + app.notify(f"cancel sent for {args[0]}") + + +@command("delete", "delete recording ") +async def _delete(app, args): + if not args: + app.notify("usage: /delete ") + return + await app.api.delete_recording(args[0]) + app.notify(f"deleted {args[0]}") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + + +@command("folder", "pick a folder (or 'new ' to create)") +async def _folder(app, args): + if args and args[0] == "new": + if len(args) < 2: + app.notify("usage: /folder new ") + return + name = " ".join(args[1:]) + await app.api.create_folder(name) + app.notify(f"folder created: {name}") + return + # No args (or non-'new' args) — re-open palette in folder-pick mode. + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/folder ") + + +@command("tag", "pick a tag to scope library") +async def _tag(app, args): + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/tag ") + + + + +@command("logs", "show server log buffer") +async def _logs(app, args): + if not app.server or not app.server.logs: + app.notify("no logs captured") + return + tail = list(app.server.logs)[-40:] + app.notify("\n".join(tail), timeout=12) + + +@command("settings", "open settings screen") +async def _settings(app, args): + from .screens.settings import SettingsScreen + app.push_screen(SettingsScreen()) + + +@command("library", "open the recordings library") +async def _library(app, args): + from .screens.library import LibraryScreen + app.push_screen(LibraryScreen()) + + +@command("jobs", "open the active-jobs list") +async def _jobs(app, args): + from .screens.library import JobsListScreen + app.push_screen(JobsListScreen()) + + +@command("welcome", "return to the welcome screen") +async def _welcome(app, args): + # Pop everything back to the welcome screen. + while len(app.screen_stack) > 1: + app.pop_screen() + + +@command("analyze", "pick a recording and run analysis") +async def _analyze(app, args): + """Three forms: + + * ``/analyze`` — open palette in analyze mode (pick recording → type) + * ``/analyze `` — skip the recording picker, choose type + * ``/analyze [extra]`` — fire immediately + """ + from .palette import Palette, _open_analysis_type_picker, seed_palette + if not args: + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/analyze ") + return + rec_id = args[0] + if len(args) == 1: + _open_analysis_type_picker(app, rec_id) + return + atype = args[1] + extra: dict = {} + if atype == "translate" and len(args) >= 3: + extra["target_language"] = args[2] + elif atype == "custom" and len(args) >= 3: + extra["custom_prompt"] = " ".join(args[2:]) + try: + await app.api.create_analysis(rec_id, atype, **extra) + app.notify(f"{atype} analysis queued for {rec_id[:8]}") + except Exception as e: + app.notify(f"analysis failed: {e}", severity="error") + + +@command("models", "pick an LLM model") +async def _models(app, args): + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/models ") + + +@command("llm", "open LLM settings") +async def _llm(app, args): + from .screens.settings import SettingsScreen + app.push_screen(SettingsScreen()) + + +@command("quit", "exit the app") +async def _quit(app, args): + app.exit() diff --git a/tui/config.py b/tui/config.py new file mode 100644 index 0000000..0165088 --- /dev/null +++ b/tui/config.py @@ -0,0 +1,45 @@ +"""TUI configuration: CLI flags, env vars, defaults.""" +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass + + +DEFAULT_API_URL = "http://127.0.0.1:8002" + + +@dataclass +class Config: + api_url: str + spawn_server: bool + debug: bool + + +def parse_args(argv: list[str] | None = None) -> Config: + parser = argparse.ArgumentParser( + prog="amicoscript-tui", + description="Terminal interface for AmicoScript transcription.", + ) + parser.add_argument( + "--api-url", + default=os.environ.get("AMICOSCRIPT_API_URL", DEFAULT_API_URL), + help="Backend API base URL (default: %(default)s)", + ) + parser.add_argument( + "--no-server", + action="store_true", + default=os.environ.get("AMICOSCRIPT_TUI_NO_SERVER", "0") == "1", + help="Do not spawn a local server; attach to an already-running one.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging.", + ) + ns = parser.parse_args(argv) + return Config( + api_url=ns.api_url.rstrip("/"), + spawn_server=not ns.no_server, + debug=ns.debug, + ) diff --git a/tui/fuzzy.py b/tui/fuzzy.py new file mode 100644 index 0000000..5d94ba9 --- /dev/null +++ b/tui/fuzzy.py @@ -0,0 +1,65 @@ +"""Tiny subsequence fuzzy matcher with scoring. + +score_match(query, text) -> int | None + Returns a score (higher = better) or None if no subsequence match. + Bonuses: prefix start, consecutive chars, word-boundary hits. + +rank(query, items, key=str) -> list[(score, item)] + Filter+rank a list. Empty query returns items in original order with score 0. +""" +from __future__ import annotations + +from typing import Callable, Iterable, TypeVar + +T = TypeVar("T") + +# Tunable weights +_PREFIX_BONUS = 60 +_BOUNDARY_BONUS = 25 +_CONSECUTIVE_BONUS = 15 +_BASE_HIT = 5 +_LENGTH_PENALTY = 0.5 # subtracted per char of text length + + +def score_match(query: str, text: str) -> int | None: + if not query: + return 0 + q = query.lower() + t = text.lower() + qi = 0 + score = 0 + last_idx = -2 + for i, ch in enumerate(t): + if qi >= len(q): + break + if ch == q[qi]: + hit = _BASE_HIT + if i == 0 and qi == 0: + hit += _PREFIX_BONUS + if i > 0 and not t[i - 1].isalnum(): + hit += _BOUNDARY_BONUS + if i == last_idx + 1: + hit += _CONSECUTIVE_BONUS + score += hit + last_idx = i + qi += 1 + if qi < len(q): + return None + score -= int(len(t) * _LENGTH_PENALTY) + return score + + +def rank( + query: str, + items: Iterable[T], + key: Callable[[T], str] = str, +) -> list[tuple[int, T]]: + out: list[tuple[int, T]] = [] + if not query: + return [(0, it) for it in items] + for it in items: + s = score_match(query, key(it)) + if s is not None: + out.append((s, it)) + out.sort(key=lambda x: x[0], reverse=True) + return out diff --git a/tui/leader.py b/tui/leader.py new file mode 100644 index 0000000..f4f3203 --- /dev/null +++ b/tui/leader.py @@ -0,0 +1,100 @@ +"""Leader-key (Space) chord dispatcher. + +Each Screen optionally declares ``leader_chords`` as a dict +``{key: (label, command_string)}``. Pressing the leader key arms the +dispatcher; the next key resolves against the current screen's chord +map and runs the associated slash-command. Esc or timeout cancels. + +The App holds one ``LeaderDispatcher`` instance and forwards key +events to ``handle_key``; the StatusBar listens for the armed/cleared +state to render the next-key hints. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.message import Message + +from .commands import run_command + +if TYPE_CHECKING: + from textual.events import Key + + from .app import AmicoTUI + + +LEADER_KEY = "space" +LEADER_TIMEOUT_S = 1.5 + + +class LeaderArmed(Message): + def __init__(self, hints: list[tuple[str, str]]) -> None: + super().__init__() + self.hints = hints + + +class LeaderCleared(Message): + pass + + +class LeaderDispatcher: + def __init__(self, app: "AmicoTUI") -> None: + self.app = app + self._armed = False + self._timer = None + + def _current_map(self) -> dict[str, tuple[str, str]]: + screen = self.app.screen + return dict(getattr(screen, "leader_chords", {}) or {}) + + def handle_key(self, event: "Key") -> bool: + """Return True if event consumed.""" + if self._armed: + if event.key == "escape": + self._clear() + return True + chord_map = self._current_map() + entry = chord_map.get(event.key) + self._clear() + if entry is None: + return True # eat unknown key while armed + _label, cmd = entry + self.app.run_worker(run_command(self.app, cmd), exclusive=False) + return True + if event.key == LEADER_KEY: + chord_map = self._current_map() + if not chord_map: + return False + self._arm(chord_map) + return True + return False + + def _arm(self, chord_map: dict[str, tuple[str, str]]) -> None: + self._armed = True + hints = [(k, lbl) for k, (lbl, _cmd) in chord_map.items()] + self._notify_bars("show_chord_hints", hints) + self._timer = self.app.set_timer(LEADER_TIMEOUT_S, self._timeout) + + def _timeout(self) -> None: + if self._armed: + self._clear() + + def _clear(self) -> None: + self._armed = False + if self._timer is not None: + try: + self._timer.stop() + except Exception: + pass + self._timer = None + self._notify_bars("clear_chord_hints") + + def _notify_bars(self, method: str, *args) -> None: + from .widgets.status_bar import StatusBar + try: + for bar in self.app.query(StatusBar): + fn = getattr(bar, method, None) + if callable(fn): + fn(*args) + except Exception: + pass diff --git a/tui/palette.py b/tui/palette.py new file mode 100644 index 0000000..8c9e6d5 --- /dev/null +++ b/tui/palette.py @@ -0,0 +1,591 @@ +"""Unified fuzzy palette with mode-based sub-pickers. + +Modes (driven by input prefix): + +* **free** — empty/plain text: fuzzy-match across commands. +* **command** — leading ``/``: filter commands. Tab completes when the + prefix uniquely identifies a single command; if the command supports + sub-picking (``/library`` / ``/folder`` / ``/tag``) completion adds a + trailing space and switches the palette into the corresponding picker. +* **library** — ``/library ``: pick a recording → open its transcript. +* **folder** — ``/folder ``: pick a folder → open library scoped to it. +* **tag** — ``/tag ``: pick a tag → open library scoped to it. +* **transcript** — ``@``: shortcut to the recording picker. + +Recent selections boost rank in subsequent opens. +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import TYPE_CHECKING, Awaitable, Callable + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option + +from .commands import COMMANDS, list_commands, run_command +from .fuzzy import score_match +from .widgets.command_input import CommandInput + +if TYPE_CHECKING: + from .app import AmicoTUI + + +MRU_MAX = 30 +MRU_BONUS = 50 + +# Commands that, when typed with a trailing space, switch the palette to +# a sub-picker. ``new`` arg of /folder is preserved by falling back to +# raw command execution on Enter when no folder matches the query. +SUBPICKERS = {"library", "folder", "tag", "analyze", "models"} +# Map command name → mode key used internally (most are 1:1; /models → "model"). +_MODE_BY_COMMAND = { + "library": "library", + "folder": "folder", + "tag": "tag", + "analyze": "analyze", + "models": "model", +} + + +@dataclass +class Entry: + kind: str # "command" | "recording" | "folder" | "tag" + key: str # stable identifier for MRU + display: str # one-line label + subtitle: str # dim hint + search_text: str # text fuzzy-matched against + on_select: Callable[["AmicoTUI"], Awaitable[None]] + + +class Palette(ModalScreen): + """Floating palette anchored at top.""" + + DEFAULT_CSS = """ + Palette { + align: center middle; + background: transparent; + } + #box { + width: 80%; + max-width: 100; + height: auto; + padding: 0; + background: $boost; + border-left: thick $primary; + } + #suggestions { + height: auto; + max-height: 14; + background: $boost; + border: none; + } + CommandInput { + border: none; + background: $boost; + height: 1; + padding: 0 1; + } + #hint { + height: 1; + color: $text-muted; + background: $boost; + padding: 0 1; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss", "Close"), + Binding("tab", "tab", show=False, priority=True), + Binding("shift+tab", "prev_suggestion", show=False, priority=True), + Binding("down", "next_suggestion", show=False), + Binding("up", "prev_suggestion", show=False), + ] + + def __init__( + self, + initial: str = "", + entries: list["Entry"] | None = None, + on_pick: Callable[["AmicoTUI", "Entry"], Awaitable[None]] | None = None, + title: str | None = None, + ) -> None: + super().__init__() + # Per-mode entry caches. + self._commands: list[Entry] = [] + self._recordings: list[Entry] = [] + self._folders: list[Entry] = [] + self._tags: list[Entry] = [] + self._models: list[Entry] = [] + # Visible after filtering, in render order. + self._visible: list[Entry] = [] + self._mode = "free" + self._initial = initial + # Optional ad-hoc mini-picker: a fixed entry list with a custom + # on-pick handler (overrides each entry's on_select). + self._ad_hoc_entries = entries + self._ad_hoc_on_pick = on_pick + self._ad_hoc_title = title + + def compose(self): + with Vertical(id="box"): + yield OptionList(id="suggestions") + yield CommandInput(placeholder="search …") + yield Static("", id="hint") + + async def on_mount(self) -> None: + inp = self.query_one(CommandInput) + inp.focus() + if self._ad_hoc_entries is not None: + # Mini-picker: no async loads, just render the fixed list. + self._refresh("") + self._update_hint("free") + if self._ad_hoc_title: + self.query_one("#hint", Static).update(self._ad_hoc_title) + return + self._load_commands() + # Prefetch recordings — used by free, library, transcript modes. + await self._load_recordings() + if self._initial: + inp.value = self._initial + inp.cursor_position = len(self._initial) + await self._on_query_change(self._initial) + else: + self._refresh("") + self._update_hint("free") + + # --- data loaders --------------------------------------------------- + + def _load_commands(self) -> None: + self._commands = [ + Entry( + kind="command", + key=f"command:{c.name}", + display=f"/{c.name}", + subtitle=c.help, + search_text=f"/{c.name} {c.help}", + on_select=_run_cmd(c.name), + ) + for c in list_commands() + ] + + async def _load_recordings(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.library(limit=500) + items = data.get("items", []) if isinstance(data, dict) else (data or []) + except Exception: + items = [] + out: list[Entry] = [] + for r in items: + rid = str(r.get("id", "")) + name = r.get("alias") or r.get("filename") or f"#{rid}" + status = r.get("status", "") + out.append(Entry( + kind="recording", + key=f"recording:{rid}", + display=f"♪ {name}", + subtitle=f"{status} · {rid[:8]}", + search_text=f"{name} {rid}", + on_select=_open_recording(rid), + )) + self._recordings = out + + async def _load_folders(self) -> None: + if self._folders: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + folders = await app.api.folders() + except Exception: + folders = [] + self._folders = entries_from_folders(folders) + + async def _load_models(self) -> None: + if self._models: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.llm_models() + except Exception: + data = {} + self._models = entries_from_models(data) + + async def _load_tags(self) -> None: + if self._tags: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + tags = await app.api.tags() + except Exception: + tags = [] + self._tags = entries_from_tags(tags) + + # --- mode parsing ---------------------------------------------------- + + def _parse(self, raw: str) -> tuple[str, str]: + """Return (mode, query).""" + if raw.startswith("@"): + return ("transcript", raw[1:].lstrip()) + if raw.startswith("/"): + rest = raw[1:] + head, sep, tail = rest.partition(" ") + if sep == " " and head in SUBPICKERS: + return (_MODE_BY_COMMAND[head], tail) + return ("command", rest) + return ("free", raw) + + def _mode_label(self, mode: str) -> str: + return { + "free": "tab complete · ctrl+p commands · esc close", + "command": "tab complete · enter run · esc close", + "library": "type to filter · enter opens transcript · esc close", + "folder": "type to filter · enter scopes library · esc close", + "tag": "type to filter · enter scopes library · esc close", + "transcript": "type to filter · enter opens transcript · esc close", + "analyze": "pick a recording · enter chooses analysis type · esc close", + "model": "pick a model · enter sets default · esc close", + }.get(mode, mode) + + def _update_hint(self, mode: str) -> None: + try: + self.query_one("#hint", Static).update(self._mode_label(mode)) + except Exception: + pass + + # --- input events ---------------------------------------------------- + + async def on_input_changed(self, event) -> None: + await self._on_query_change(event.value) + + async def _on_query_change(self, raw: str) -> None: + mode, _q = self._parse(raw) + if mode != self._mode: + self._mode = mode + self._update_hint(mode) + if mode == "folder": + await self._load_folders() + elif mode == "tag": + await self._load_tags() + elif mode == "model": + await self._load_models() + self._refresh(raw) + + async def on_input_submitted(self, event) -> None: + await self._activate_highlighted(fallback_text=event.value) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + self.run_worker(self._activate(event.option.id), exclusive=False) + + # --- filtering & ranking --------------------------------------------- + + def _pool_for_mode(self, mode: str) -> list[Entry]: + if self._ad_hoc_entries is not None: + return self._ad_hoc_entries + if mode == "command": + return self._commands + if mode == "library" or mode == "transcript" or mode == "analyze": + return self._recordings + if mode == "folder": + return self._folders + if mode == "tag": + return self._tags + if mode == "model": + return self._models + # free: commands first, then recordings + return self._commands + self._recordings + + def _refresh(self, raw: str) -> None: + mode, query = self._parse(raw) + pool = self._pool_for_mode(mode) + mru = list(getattr(self.app, "_palette_mru", [])) + mru_rank = {k: len(mru) - i for i, k in enumerate(mru)} + + scored: list[tuple[int, Entry]] = [] + if query: + for e in pool: + s = score_match(query, e.search_text) + if s is None: + continue + if e.key in mru_rank: + s += MRU_BONUS + mru_rank[e.key] + scored.append((s, e)) + scored.sort(key=lambda x: x[0], reverse=True) + else: + mru_set = set(mru_rank) + mru_entries = [e for e in pool if e.key in mru_set] + mru_entries.sort(key=lambda e: -mru_rank[e.key]) + other = [e for e in pool if e.key not in mru_set] + scored = [(0, e) for e in mru_entries + other] + + self._visible = [e for _s, e in scored[:200]] + lst = self.query_one("#suggestions", OptionList) + lst.clear_options() + for e in self._visible: + lst.add_option(Option( + f"[b]{e.display}[/b] [dim]{e.subtitle}[/dim]", + id=e.key, + )) + if self._visible: + lst.highlighted = 0 + + # --- actions --------------------------------------------------------- + + def action_next_suggestion(self) -> None: + lst = self.query_one("#suggestions", OptionList) + if lst.option_count == 0: + return + lst.highlighted = ( + (lst.highlighted + 1) % lst.option_count + if lst.highlighted is not None + else 0 + ) + + def action_prev_suggestion(self) -> None: + lst = self.query_one("#suggestions", OptionList) + if lst.option_count == 0: + return + lst.highlighted = ( + (lst.highlighted - 1) % lst.option_count + if lst.highlighted is not None + else lst.option_count - 1 + ) + + async def action_tab(self) -> None: + """Tab: in command mode, complete to a unique match; otherwise cycle.""" + inp = self.query_one(CommandInput) + raw = inp.value + mode, query = self._parse(raw) + if mode == "command": + # Find commands whose name starts with the typed query (case-insens). + q = query.lower().split(" ", 1)[0] + matches = [c.name for c in list_commands() if c.name.startswith(q)] + if len(matches) == 1: + completed = "/" + matches[0] + suffix = " " if matches[0] in SUBPICKERS else "" + inp.value = completed + suffix + inp.cursor_position = len(inp.value) + await self._on_query_change(inp.value) + return + if len(matches) > 1: + # Complete to longest common prefix. + lcp = _longest_common_prefix(matches) + if lcp and lcp != q: + inp.value = "/" + lcp + inp.cursor_position = len(inp.value) + await self._on_query_change(inp.value) + return + # Fallback: cycle suggestions. + self.action_next_suggestion() + + # --- activation ------------------------------------------------------ + + async def _activate_highlighted(self, fallback_text: str = "") -> None: + lst = self.query_one("#suggestions", OptionList) + if lst.option_count and lst.highlighted is not None: + opt = lst.get_option_at_index(lst.highlighted) + if opt and opt.id: + await self._activate(opt.id) + return + # No match — if input looks like a raw command, run it. + text = fallback_text.strip() + if text.startswith("/"): + self.app.pop_screen() + await run_command(self.app, text) + + async def _activate(self, entry_key: str) -> None: + entry = ( + next((e for e in self._visible if e.key == entry_key), None) + or next((e for e in self._all_entries() if e.key == entry_key), None) + ) + if entry is None: + return + _push_mru(self.app, entry.key) + # Ad-hoc mini-picker (e.g. analysis-type chooser) — defer to caller. + if self._ad_hoc_on_pick is not None: + self.app.pop_screen() + await self._ad_hoc_on_pick(self.app, entry) + return + # In analyze mode, picking a recording opens the type chooser. + if self._mode == "analyze" and entry.kind == "recording": + rec_id = entry.key.split(":", 1)[1] + self.app.pop_screen() + _open_analysis_type_picker(self.app, rec_id) + return + self.app.pop_screen() + await entry.on_select(self.app) + + def _all_entries(self) -> list[Entry]: + if self._ad_hoc_entries is not None: + return list(self._ad_hoc_entries) + return ( + self._commands + + self._recordings + + self._folders + + self._tags + + self._models + ) + + +# --- helpers -------------------------------------------------------- + + +def _longest_common_prefix(strings: list[str]) -> str: + if not strings: + return "" + s1, s2 = min(strings), max(strings) + for i, ch in enumerate(s1): + if i >= len(s2) or s2[i] != ch: + return s1[:i] + return s1 + + +# --- selection adapters -------------------------------------------------- + + +def _run_cmd(name: str): + async def go(app: "AmicoTUI") -> None: + await run_command(app, name) + return go + + +def _open_recording(rec_id: str): + async def go(app: "AmicoTUI") -> None: + from .screens.transcript import TranscriptScreen + app.push_screen(TranscriptScreen(rec_id)) + return go + + +def _open_library_folder(folder_id: str): + async def go(app: "AmicoTUI") -> None: + from .screens.library import LibraryScreen + app.push_screen(LibraryScreen(folder_id=folder_id, title=f"Folder · {folder_id[:8]}")) + return go + + +def _open_library_tag(tag_id: str): + async def go(app: "AmicoTUI") -> None: + from .screens.library import LibraryScreen + app.push_screen(LibraryScreen(tag_id=tag_id, title=f"Tag · {tag_id[:8]}")) + return go + + +def _set_default_model(name: str): + async def go(app: "AmicoTUI") -> None: + try: + await app.api.save_llm_settings(model_name=name) + app.notify(f"default LLM model: {name}") + except Exception as e: + app.notify(f"failed to save: {e}", severity="error") + return go + + +ANALYSIS_TYPES = [ + ("summary", "Summarise the transcript"), + ("action_items", "Extract action items"), + ("translate", "Translate transcript"), + ("custom", "Run a custom prompt"), +] + + +def _open_analysis_type_picker(app: "AmicoTUI", rec_id: str) -> None: + entries = [ + Entry( + kind="analysis_type", + key=f"analysis_type:{name}", + display=f"✦ {name}", + subtitle=desc, + search_text=name, + on_select=_noop, + ) + for name, desc in ANALYSIS_TYPES + ] + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + atype = entry.key.split(":", 1)[1] + try: + await app.api.create_analysis(rec_id, atype) + app.notify(f"{atype} analysis queued for {rec_id[:8]}") + except Exception as e: + app.notify(f"analysis failed: {e}", severity="error") + + app.push_screen(Palette(entries=entries, on_pick=on_pick, title="choose analysis type")) + + +async def _noop(app: "AmicoTUI") -> None: + return None + + +def entries_from_folders(folders: list[dict] | None) -> list[Entry]: + return [ + Entry( + kind="folder", + key=f"folder:{f.get('id')}", + display=f"▣ {f.get('name', '?')}", + subtitle=f"folder · id {str(f.get('id'))[:8]}", + search_text=str(f.get("name", "")), + on_select=_open_library_folder(str(f.get("id"))), + ) + for f in (folders or []) if f.get("id") is not None + ] + + +def entries_from_tags(tags: list[dict] | None) -> list[Entry]: + return [ + Entry( + kind="tag", + key=f"tag:{t.get('id')}", + display=f"# {t.get('name', '?')}", + subtitle=f"tag · id {str(t.get('id'))[:8]}", + search_text=str(t.get("name", "")), + on_select=_open_library_tag(str(t.get("id"))), + ) + for t in (tags or []) if t.get("id") is not None + ] + + +def entries_from_models(data) -> list[Entry]: + items = data.get("models") if isinstance(data, dict) else (data or []) + names: list[str] = [] + for it in items or []: + if isinstance(it, str): + names.append(it) + elif isinstance(it, dict): + n = it.get("name") or it.get("model") + if n: + names.append(str(n)) + return [ + Entry( + kind="model", + key=f"model:{n}", + display=f"⚡ {n}", + subtitle="set as default LLM model", + search_text=n, + on_select=_set_default_model(n), + ) + for n in names + ] + + +def seed_palette(pal: "Palette", text: str) -> None: + """Helper for commands that re-open the palette pre-seeded.""" + try: + inp = pal.query_one(CommandInput) + inp.value = text + inp.cursor_position = len(text) + except Exception: + pass + + +def _push_mru(app: "AmicoTUI", key: str) -> None: + mru: deque = getattr(app, "_palette_mru", None) + if mru is None: + mru = deque(maxlen=MRU_MAX) + app._palette_mru = mru # type: ignore[attr-defined] + try: + mru.remove(key) + except ValueError: + pass + mru.append(key) diff --git a/tui/playback.py b/tui/playback.py new file mode 100644 index 0000000..94a6abc --- /dev/null +++ b/tui/playback.py @@ -0,0 +1,98 @@ +"""Audio playback via system subprocess (afplay/ffplay/aplay). + +Minimal — start, stop, status. No precise seek/scrub yet (would require +a controllable player like libmpv). Spacebar toggles play/pause = start +from offset, kill to pause. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def _find_player() -> tuple[str, list[str]] | None: + """Return (binary, base_args). Prefer ffplay (supports -ss seek).""" + if shutil.which("ffplay"): + return "ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"] + # Bundled ffplay alongside ffmpeg? + from .waveform import _ffmpeg_bin + ff = _ffmpeg_bin() + if ff: + ffplay = Path(ff).with_name("ffplay") + if ffplay.is_file(): + return str(ffplay), ["-nodisp", "-autoexit", "-loglevel", "quiet"] + if sys.platform == "darwin" and shutil.which("afplay"): + return "afplay", [] + if sys.platform.startswith("linux"): + for b in ("paplay", "aplay"): + if shutil.which(b): + return b, [] + return None + + +class Player: + def __init__(self) -> None: + self.proc: subprocess.Popen | None = None + self.path: Path | None = None + self.offset_s: float = 0.0 + self._started_at: float = 0.0 + self._supports_seek: bool = False + + def is_playing(self) -> bool: + return self.proc is not None and self.proc.poll() is None + + def elapsed(self) -> float: + """Seconds since playback started (0 if stopped).""" + if not self.is_playing(): + return 0.0 + return time.monotonic() - self._started_at + + def position(self) -> float: + """Approximate playback position in the file, in seconds.""" + base = self.offset_s if self._supports_seek else 0.0 + return base + self.elapsed() + + def play(self, path: Path, offset_s: float = 0.0) -> str | None: + """Start playback. Return error message or None.""" + self.stop() + choice = _find_player() + if choice is None: + return "no audio player found (install ffplay or use macOS/Linux)" + binary, args = choice + cmd = [binary, *args] + name = Path(binary).name + if name == "afplay": + # afplay supports -t (duration); offset via -t not seek. Skip offset. + cmd.append(str(path)) + elif name.startswith("ffplay"): + if offset_s > 0: + cmd.extend(["-ss", f"{offset_s:.2f}"]) + cmd.append(str(path)) + else: + cmd.append(str(path)) + try: + self.proc = subprocess.Popen( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except OSError as e: + return f"play failed: {e}" + self.path = path + self.offset_s = offset_s + self._started_at = time.monotonic() + self._supports_seek = name.startswith("ffplay") + return None + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + try: + self.proc.terminate() + self.proc.wait(timeout=1) + except Exception: + try: + self.proc.kill() + except Exception: + pass + self.proc = None diff --git a/tui/requirements.txt b/tui/requirements.txt new file mode 100644 index 0000000..5818040 --- /dev/null +++ b/tui/requirements.txt @@ -0,0 +1,6 @@ +textual>=0.70 +httpx>=0.27 +httpx-sse>=0.4 +pyperclip>=1.8 +numpy>=1.24 +soundfile>=0.12 diff --git a/tui/screens/__init__.py b/tui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/screens/jobs.py b/tui/screens/jobs.py new file mode 100644 index 0000000..8a159a2 --- /dev/null +++ b/tui/screens/jobs.py @@ -0,0 +1,84 @@ +"""Single-job live progress screen via SSE.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Header, Log, Static + +from ..sse import stream_job +from ..widgets.progress_bar import JobProgress +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +class JobScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + Binding("c", "cancel", "Cancel job"), + ] + + DEFAULT_CSS = """ + JobScreen { layout: vertical; } + #title { padding: 0 1; height: 1; } + Log { height: 1fr; border: tall $panel; } + """ + + def __init__(self, job_id: str, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.job_id = job_id + + def compose(self): + yield Header() + with Vertical(): + yield Static(f"job [b]{self.job_id}[/b]", id="title") + yield JobProgress(id="prog") + yield Log(id="log", highlight=False, max_lines=2000) + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + self.run_worker(self._stream(), exclusive=True) + + async def _stream(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + prog = self.query_one(JobProgress) + log = self.query_one(Log) + status = self.query_one(StatusBar) + try: + async for evt in stream_job(app.api.client, app.api.base_url, self.job_id): + if not isinstance(evt, dict): + continue + if "progress" in evt: + try: + prog.progress = float(evt["progress"]) + except (TypeError, ValueError): + pass + if "message" in evt and evt["message"]: + prog.label = str(evt["message"]) + log.write_line(str(evt["message"])) + if "log" in evt and evt["log"]: + log.write_line(str(evt["log"])) + if evt.get("status") in {"done", "error", "completed", "cancelled"}: + status.flash(f"job {evt['status']}") + break + except Exception as e: + status.set_connection(f"stream error: {e}", ok=False) + + def action_pop(self) -> None: + self.app.pop_screen() + + def action_cancel(self) -> None: + self.run_worker(self._cancel(), exclusive=False) + + async def _cancel(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + await app.api.cancel_job(self.job_id) + self.query_one(StatusBar).flash("cancel sent") + except Exception as e: + self.query_one(StatusBar).flash(f"cancel failed: {e}") diff --git a/tui/screens/library.py b/tui/screens/library.py new file mode 100644 index 0000000..741848b --- /dev/null +++ b/tui/screens/library.py @@ -0,0 +1,259 @@ +"""Library panel: list of recordings with keyboard navigation.""" +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import DataTable, Footer, Header + +from ..clipboard import copy_to_clipboard + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +STATUS_ICON = { + "pending": "○", + "queued": "○", + "transcribing": "◐", + "diarizing": "◑", + "done": "●", + "completed": "●", + "error": "✗", +} + + +def _fmt_duration(seconds): + if not seconds: + return "--:--" + s = int(seconds) + h, rem = divmod(s, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h:d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" + + +def _fmt_date(value): + if value is None or value == "": + return "" + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M") + except (ValueError, OSError): + return "" + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime( + "%Y-%m-%d %H:%M" + ) + except ValueError: + return value[:16] + + +class LibraryPanel(Widget): + """Recording list panel, embeddable in a tab.""" + + BINDINGS = [ + Binding("r", "refresh", "Refresh"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + Binding("G", "cursor_bottom", show=False), + Binding("g,g", "cursor_top", show=False), + Binding("d", "delete_row", "Delete"), + Binding("y", "copy_name", "Copy name"), + Binding("enter", "open", "Open"), + ] + + DEFAULT_CSS = """ + LibraryPanel { layout: vertical; height: 1fr; } + DataTable { height: 1fr; } + """ + + def __init__( + self, + status_filter: str | None = None, + folder_id: str | None = None, + tag_id: str | None = None, + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.table: DataTable | None = None + self.row_keys: list[str] = [] + self.status_filter = status_filter + self.folder_id = folder_id + self.tag_id = tag_id + + def compose(self): + with Vertical(): + yield DataTable(cursor_type="row", zebra_stripes=True) + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.add_columns("", "Name", "Duration", "Status", "Created") + self.refresh_library() + + def on_show(self) -> None: + # Re-fetch when tab re-activated. + if self.table is not None: + self.refresh_library() + + # --- actions ---------------------------------------------------- + + def action_refresh(self) -> None: + self.refresh_library() + + def action_cursor_down(self) -> None: + if self.table: + self.table.action_cursor_down() + + def action_cursor_up(self) -> None: + if self.table: + self.table.action_cursor_up() + + def action_cursor_top(self) -> None: + if self.table: + self.table.move_cursor(row=0) + + def action_cursor_bottom(self) -> None: + if self.table and self.table.row_count: + self.table.move_cursor(row=self.table.row_count - 1) + + def action_delete_row(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + self.app.notify(f"/delete {rec_id} to confirm deletion") + + def action_copy_name(self) -> None: + rec_id = self._selected_id() + if rec_id is None or self.table is None: + return + row = self.table.get_row_at(self.table.cursor_row) + name = str(row[1]) + if copy_to_clipboard(name): + self.app.notify(f"copied: {name}") + + def action_open(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + from .transcript import TranscriptScreen + self.app.push_screen(TranscriptScreen(rec_id)) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + self.action_open() + + # --- data load -------------------------------------------------- + + def refresh_library(self) -> None: + self.run_worker(self._load(), exclusive=True) + + async def _load(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.library( + limit=200, + status=self.status_filter, + folder_id=self.folder_id, + tag_id=self.tag_id, + ) + except Exception as e: + self.app.notify(f"library load failed: {e}", severity="error") + return + items = data.get("items", []) if isinstance(data, dict) else data + assert self.table is not None + self.table.clear() + self.row_keys.clear() + for r in items: + icon = STATUS_ICON.get(r.get("status", ""), "·") + name = r.get("alias") or r.get("filename") or f"#{r.get('id')}" + self.table.add_row( + icon, + name, + _fmt_duration(r.get("duration")), + r.get("status", ""), + _fmt_date(r.get("created_at")), + ) + self.row_keys.append(str(r["id"])) + + def _selected_id(self) -> str | None: + if not self.table or self.table.row_count == 0: + return None + idx = self.table.cursor_row + if 0 <= idx < len(self.row_keys): + return self.row_keys[idx] + return None + + +class LibraryScreen(Screen): + """Full-screen library view. Pushed by leader chord or /library.""" + + BINDINGS = [ + Binding("escape", "pop", "Back"), + ] + + leader_chords = { + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "h": ("Welcome", "/welcome"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + LibraryScreen { layout: vertical; } + LibraryPanel { height: 1fr; } + """ + + def __init__( + self, + status_filter: str | None = None, + folder_id: str | None = None, + tag_id: str | None = None, + title: str | None = None, + ) -> None: + super().__init__() + self.status_filter = status_filter + self.folder_id = folder_id + self.tag_id = tag_id + self.title = title or ( + "Library" if not status_filter else f"Library · {status_filter}" + ) + + def compose(self): + from ..widgets.status_bar import StatusBar + yield Header(show_clock=False) + with Vertical(): + yield LibraryPanel( + status_filter=self.status_filter, + folder_id=self.folder_id, + tag_id=self.tag_id, + id="library_panel", + ) + yield StatusBar(id="statusbar") + yield Footer() + + def on_mount(self) -> None: + self.query_one(LibraryPanel).query_one(DataTable).focus() + + def action_pop(self) -> None: + self.app.pop_screen() + + +class JobsListScreen(LibraryScreen): + """Library filtered to in-flight jobs.""" + + leader_chords = { + "l": ("Library", "/library"), + "s": ("Settings", "/settings"), + "h": ("Welcome", "/welcome"), + "q": ("Quit", "/quit"), + } + + def __init__(self) -> None: + super().__init__(status_filter="transcribing") + self.title = "Jobs" diff --git a/tui/screens/settings.py b/tui/screens/settings.py new file mode 100644 index 0000000..224b2ed --- /dev/null +++ b/tui/screens/settings.py @@ -0,0 +1,107 @@ +"""Settings panel: HF token + LLM config.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Button, Footer, Header, Input, Label + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +class SettingsPanel(Widget): + DEFAULT_CSS = """ + SettingsPanel { layout: vertical; height: 1fr; } + Label { padding: 1 2 0 2; color: $accent; } + Input { margin: 0 2; } + Button { margin: 1 2 0 2; } + """ + + def compose(self): + with Vertical(): + yield Label("Hugging Face token (for diarization)") + yield Input(id="hf", password=True, placeholder="hf_...") + yield Label("LLM base URL") + yield Input(id="llm_url", placeholder="http://localhost:11434") + yield Label("LLM model name") + yield Input(id="llm_model", placeholder="llama3.1") + yield Label("LLM API key (optional)") + yield Input(id="llm_key", password=True) + yield Button("Save", id="save", variant="primary") + + def on_mount(self) -> None: + self.run_worker(self._load(), exclusive=True) + + async def _load(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + s = await app.api.settings() + self.query_one("#hf", Input).value = s.get("hf_token") or "" + except Exception as e: + self.app.notify(f"settings load failed: {e}", severity="error") + return + try: + llm = await app.api.llm_settings() + self.query_one("#llm_url", Input).value = llm.get("base_url") or "" + self.query_one("#llm_model", Input).value = llm.get("model_name") or "" + self.query_one("#llm_key", Input).value = llm.get("api_key") or "" + except Exception: + pass + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id != "save": + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + await app.api.save_settings(hf_token=self.query_one("#hf", Input).value) + await app.api.save_llm_settings( + base_url=self.query_one("#llm_url", Input).value or None, + model_name=self.query_one("#llm_model", Input).value or None, + api_key=self.query_one("#llm_key", Input).value or None, + ) + self.app.notify("settings saved") + except Exception as e: + self.app.notify(f"save failed: {e}", severity="error") + + +class SettingsScreen(Screen): + """Full-screen settings view.""" + + BINDINGS = [Binding("escape", "pop", "Back")] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "h": ("Welcome", "/welcome"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + SettingsScreen { layout: vertical; } + SettingsPanel { height: 1fr; } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "Settings" + + def compose(self): + from ..widgets.status_bar import StatusBar + yield Header(show_clock=False) + with Vertical(): + yield SettingsPanel(id="settings_panel") + yield StatusBar(id="statusbar") + yield Footer() + + def on_mount(self) -> None: + try: + self.query_one(SettingsPanel).query_one("#hf", Input).focus() + except Exception: + pass + + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/transcript.py b/tui/screens/transcript.py new file mode 100644 index 0000000..c1a13dc --- /dev/null +++ b/tui/screens/transcript.py @@ -0,0 +1,204 @@ +"""Transcript screen: waveform + segments + playback.""" +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Header, OptionList, Static + +from ..clipboard import copy_to_clipboard +from ..playback import Player +from ..waveform import compute_levels_async +from ..widgets.segment_list import SegmentList +from ..widgets.status_bar import StatusBar +from ..widgets.waveform_view import WaveformView + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +class TranscriptScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + Binding("y", "copy_segment", "Copy seg"), + Binding("Y", "copy_all", "Copy all"), + Binding("space", "toggle_play", "Play/Pause"), + Binding("s", "stop_play", "Stop"), + Binding("ctrl+a", "analyze", "Analyze"), + ] + + DEFAULT_CSS = """ + TranscriptScreen { layout: vertical; } + #title { padding: 0 1; height: 1; color: $accent; } + """ + + def __init__(self, recording_id: str, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.recording_id = recording_id + self._tmp_audio: Path | None = None + self.player = Player() + self.duration_s: float = 0.0 + self._anim_timer = None + + def compose(self): + yield Header(show_clock=False) + with Vertical(): + yield Static("loading...", id="title") + yield WaveformView(id="wave") + yield SegmentList(id="segments") + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + self.run_worker(self._load(), exclusive=True) + # Animate at ~15 fps. + self._anim_timer = self.set_interval(1 / 15, self._tick, pause=False) + + async def _load(self) -> None: + """Fast path: title + transcript visible immediately. Waveform separate worker.""" + app: "AmicoTUI" = self.app # type: ignore[assignment] + title = self.query_one("#title", Static) + seg_list = self.query_one(SegmentList) + status = self.query_one(StatusBar) + + try: + rec = await app.api.recording(self.recording_id) + except Exception as e: + title.update(f"error: {e}") + return + name = rec.get("alias") or rec.get("filename") or self.recording_id + self.duration_s = float(rec.get("duration") or 0.0) + title.update( + f"[b]{name}[/b] · {self._fmt_dur(self.duration_s)} · " + f"status: {rec.get('status', '?')}" + ) + + try: + tdata = await app.api.transcript(self.recording_id) + segs = ( + tdata.get("segments") + or tdata.get("json_data", {}).get("segments") + or [] + ) + seg_list.load(segs) + except Exception as e: + status.set_connection(f"transcript load failed: {e}", ok=False) + + # Waveform + audio: separate non-blocking worker so the screen is usable now. + self.run_worker(self._load_audio(), exclusive=False, name="audio") + + async def _load_audio(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + wave = self.query_one(WaveformView) + status = self.query_one(StatusBar) + status.flash("loading audio...") + try: + url = f"/api/recordings/{self.recording_id}/audio" + tmp = tempfile.NamedTemporaryFile( + prefix="amicoscript-tui-", suffix=".audio", delete=False + ) + self._tmp_audio = Path(tmp.name) + tmp.close() + async with app.api.client.stream("GET", url) as r: + r.raise_for_status() + with self._tmp_audio.open("wb") as out: + async for chunk in r.aiter_bytes(): + out.write(chunk) + width = max(40, self.size.width - 6) + wave.levels = await compute_levels_async(self._tmp_audio, width=width) + status.flash("audio ready · Space to play") + except Exception as e: + status.flash(f"audio: {e}") + + def on_unmount(self) -> None: + self.player.stop() + if self._anim_timer is not None: + self._anim_timer.stop() + if self._tmp_audio and self._tmp_audio.exists(): + try: + self._tmp_audio.unlink() + except OSError: + pass + + # --- animation -------------------------------------------------- + + def _tick(self) -> None: + wave = self.query_one(WaveformView) + if self.player.is_playing() and self.duration_s > 0: + pos = self.player.position() + wave.position = max(0.0, min(1.0, pos / self.duration_s)) + # else leave position as-is (last cursor stays) + + # --- segment Enter → play --------------------------------------- + + def on_option_list_option_selected( + self, event: OptionList.OptionSelected + ) -> None: + seg = self.query_one(SegmentList).selected_segment() + if not seg: + return + self._play_from(float(seg.get("start", 0.0))) + + # --- actions ---------------------------------------------------- + + def action_pop(self) -> None: + self.app.pop_screen() + + def action_copy_segment(self) -> None: + seg = self.query_one(SegmentList).selected_segment() + if not seg: + return + text = (seg.get("text") or "").strip() + if copy_to_clipboard(text): + self.query_one(StatusBar).flash("copied segment") + + def action_copy_all(self) -> None: + segs = self.query_one(SegmentList).segments + text = "\n".join((s.get("text") or "").strip() for s in segs) + if copy_to_clipboard(text): + self.query_one(StatusBar).flash(f"copied {len(segs)} segments") + + def action_toggle_play(self) -> None: + if self.player.is_playing(): + self.player.stop() + self.query_one(StatusBar).flash("paused") + return + seg = self.query_one(SegmentList).selected_segment() + offset = float(seg.get("start", 0.0)) if seg else 0.0 + self._play_from(offset) + + def action_stop_play(self) -> None: + self.player.stop() + wave = self.query_one(WaveformView) + wave.position = 0.0 + self.query_one(StatusBar).flash("stopped") + + def action_analyze(self) -> None: + from ..palette import _open_analysis_type_picker + _open_analysis_type_picker(self.app, self.recording_id) + + # --- helpers ---------------------------------------------------- + + def _play_from(self, offset_s: float) -> None: + if not self._tmp_audio or not self._tmp_audio.exists(): + self.query_one(StatusBar).flash("audio not loaded yet") + return + err = self.player.play(self._tmp_audio, offset_s=offset_s) + status = self.query_one(StatusBar) + if err: + status.flash(err) + else: + status.flash(f"playing @ {self._fmt_dur(offset_s)}") + + @staticmethod + def _fmt_dur(seconds: float) -> str: + s = int(seconds or 0) + h, rem = divmod(s, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h:d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py new file mode 100644 index 0000000..bebb2a5 --- /dev/null +++ b/tui/screens/welcome.py @@ -0,0 +1,141 @@ +"""Welcome screen: hints + leader chord landing. + +No tabs. Bare ``l``/``j``/``s`` are shortcuts. ``Space`` arms the +leader and the StatusBar shows next-key hints. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Footer, Header, Static + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +LOGO = "\n".join([ + " ▄▄▀▀▀▄▄ ", + " ▄▄▀ ▀█▄ ", + " ▄▀ ▄██▄ ▀▄", + " █ █ ▄ ▀███ █", + " ▀█▄█▀█▄▀▄████▄ █", + "▀█ ██ ██ ▀▄▄█▄▀ █", + " █ ▀▀ ██▄ █", + " ▀▄ ████ ▄▀", + " ▀▀▄ █▀ ▀▄▀ ", + " ▀▀▄▄▄▀▀ ", +]) + +HINTS = """\ +[b]AmicoScript[/b] · local-first transcription + +[b $accent]Space l[/] Library [b $accent]Space j[/] Jobs +[b $accent]Space s[/] Settings [b $accent]Space ?[/] Help +[b $accent]Space q[/] Quit [b $accent]/ · ^K[/] Palette + +Direct keys (welcome only): [b]l[/] [b]j[/] [b]s[/] +Drop an audio file onto the terminal to transcribe. +""" + + +class WelcomeScreen(Screen): + """Landing screen. Bare letters jump; Space arms leader chord.""" + + BINDINGS = [ + Binding("l", "go('/library')", show=False), + Binding("j", "go('/jobs')", show=False), + Binding("s", "go('/settings')", show=False), + Binding("question_mark", "help", "Help"), + Binding("q", "quit", "Quit"), + ] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + WelcomeScreen { layout: vertical; } + #main { + width: 100%; + height: 1fr; + align: center middle; + } + #stack { + width: auto; + height: auto; + } + #logo { + color: $primary; + text-style: bold; + content-align: center middle; + width: 60; + height: auto; + margin-bottom: 1; + } + #version { + color: $accent; + content-align: center middle; + height: 1; + width: 60; + margin-bottom: 1; + } + #hints { + height: auto; + width: 60; + padding: 1 2; + background: $panel; + border: tall $primary; + color: $text; + } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "AmicoScript" + + def compose(self): + from textual.containers import Container + from ..widgets.status_bar import StatusBar + yield Header(show_clock=False) + with Container(id="main"): + with Vertical(id="stack"): + yield Static(LOGO, id="logo") + yield Static("local-first · privacy-focused · whisper", id="version") + yield Static(HINTS, id="hints") + yield StatusBar(id="statusbar") + yield Footer() + + def on_mount(self) -> None: + self.run_worker(self._fetch_version(), exclusive=True) + + async def _fetch_version(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + v = await app.api.version() + ver = v.get("version") if isinstance(v, dict) else str(v) + if ver: + self.query_one("#version", Static).update( + f"v{ver} · local-first · privacy-focused · whisper" + ) + except Exception: + pass + + async def action_go(self, cmd: str) -> None: + from ..commands import run_command + await run_command(self.app, cmd) + + def action_help(self) -> None: + self.app.notify( + "Space + l/j/s/q · / palette · ? help", + timeout=6, + ) + + def action_quit(self) -> None: + self.app.exit() diff --git a/tui/server.py b/tui/server.py new file mode 100644 index 0000000..3ebe2ff --- /dev/null +++ b/tui/server.py @@ -0,0 +1,138 @@ +"""Backend server lifecycle: probe, spawn, supervise, terminate.""" +from __future__ import annotations + +import atexit +import os +import signal +import subprocess +import sys +import threading +import time +from collections import deque +from pathlib import Path +from typing import Deque +from urllib.parse import urlparse +from urllib.request import urlopen +from urllib.error import URLError + + +READY_TIMEOUT_S = 30.0 +PROBE_INTERVAL_S = 0.5 +LOG_BUFFER_LINES = 2000 + + +class ServerManager: + """Spawn and supervise the AmicoScript backend as a subprocess. + + If a server already responds at `api_url`, attach to it instead of + spawning. The spawned process inherits AMICOSCRIPT_NO_BROWSER=1 so it + does not pop a browser window. + """ + + def __init__(self, api_url: str, spawn: bool = True) -> None: + self.api_url = api_url.rstrip("/") + self.spawn_requested = spawn + self.process: subprocess.Popen | None = None + self.logs: Deque[str] = deque(maxlen=LOG_BUFFER_LINES) + self._log_thread: threading.Thread | None = None + self._shutdown_called = False + atexit.register(self.shutdown) + + # --- public API -------------------------------------------------- + + def ensure_ready(self) -> bool: + """Return True once `GET /api/version` returns 200. + + Probes the existing URL first; spawns a subprocess if missing and + allowed. + """ + if self._probe(): + return True + if not self.spawn_requested: + return False + if not self._is_loopback(self.api_url): + # Refuse to spawn when targeting a remote host. + return False + self._spawn() + return self._wait_ready(READY_TIMEOUT_S) + + def shutdown(self) -> None: + if self._shutdown_called: + return + self._shutdown_called = True + proc = self.process + if proc is None or proc.poll() is not None: + return + try: + if sys.platform == "win32": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + except Exception: + pass + + def is_alive(self) -> bool: + if self.process is None: + return self._probe() + return self.process.poll() is None + + # --- internal ---------------------------------------------------- + + @staticmethod + def _is_loopback(url: str) -> bool: + host = urlparse(url).hostname or "" + return host in {"127.0.0.1", "localhost", "::1"} + + def _probe(self) -> bool: + try: + with urlopen(f"{self.api_url}/api/version", timeout=1.5) as r: + return r.status == 200 + except (URLError, OSError, ValueError): + return False + + def _wait_ready(self, timeout_s: float) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if self._probe(): + return True + if self.process and self.process.poll() is not None: + return False + time.sleep(PROBE_INTERVAL_S) + return False + + def _spawn(self) -> None: + repo_root = Path(__file__).resolve().parent.parent + run_py = repo_root / "run.py" + env = os.environ.copy() + env["AMICOSCRIPT_NO_BROWSER"] = "1" + kwargs: dict = dict( + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + text=True, + ) + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + self.process = subprocess.Popen( + [sys.executable, str(run_py)], **kwargs + ) + self._log_thread = threading.Thread( + target=self._drain_logs, daemon=True + ) + self._log_thread.start() + + def _drain_logs(self) -> None: + proc = self.process + if proc is None or proc.stdout is None: + return + for line in proc.stdout: + self.logs.append(line.rstrip("\n")) diff --git a/tui/sse.py b/tui/sse.py new file mode 100644 index 0000000..77fa9c4 --- /dev/null +++ b/tui/sse.py @@ -0,0 +1,28 @@ +"""SSE streaming helper for job progress events.""" +from __future__ import annotations + +import json +from typing import AsyncIterator + +import httpx +from httpx_sse import aconnect_sse + + +async def stream_job( + client: httpx.AsyncClient, base_url: str, job_id: str +) -> AsyncIterator[dict]: + """Yield decoded JSON event payloads from /api/jobs/{id}/stream. + + Each event from the backend has a JSON body. Non-JSON or empty lines + are skipped. Caller is responsible for cancellation via task cancel. + """ + url = f"{base_url}/api/jobs/{job_id}/stream" + async with aconnect_sse(client, "GET", url) as source: + async for event in source.aiter_sse(): + data = event.data + if not data: + continue + try: + yield json.loads(data) + except json.JSONDecodeError: + yield {"raw": data} diff --git a/tui/waveform.py b/tui/waveform.py new file mode 100644 index 0000000..6666f41 --- /dev/null +++ b/tui/waveform.py @@ -0,0 +1,174 @@ +"""Render audio waveform as unicode bars for the terminal. + +Decodes any audio/video format via ffmpeg to s16le mono PCM, then +downsamples by peak amplitude per bucket. Both raw-levels (for rich +multi-row rendering) and single-line string renderers are exposed. +""" +from __future__ import annotations + +import asyncio +import shutil +import subprocess +from pathlib import Path + +BLOCKS = " ▁▂▃▄▅▆▇█" # 9 levels + + +def _ffmpeg_bin() -> str | None: + found = shutil.which("ffmpeg") + if found: + return found + candidates = [ + Path.home() / ".amicoscript" / "data" / "bin" / "ffmpeg", + Path.cwd() / "amicoscript-data" / "bin" / "ffmpeg", + ] + for c in candidates: + if c.is_file(): + return str(c) + win = c.with_suffix(".exe") + if win.is_file(): + return str(win) + return None + + +def _decode_pcm(path: Path, sr: int = 4000) -> bytes | None: + ffmpeg = _ffmpeg_bin() + if not ffmpeg: + return None + try: + proc = subprocess.run( + [ + ffmpeg, "-v", "quiet", "-nostdin", + "-i", str(path), + "-f", "s16le", + "-acodec", "pcm_s16le", + "-ac", "1", + "-ar", str(sr), + "-", + ], + capture_output=True, + timeout=60, + ) + except (subprocess.TimeoutExpired, OSError): + return None + if proc.returncode != 0: + return None + return proc.stdout + + +async def _decode_pcm_async(path: Path, sr: int = 4000) -> bytes | None: + """Non-blocking ffmpeg decode for use inside event loop.""" + ffmpeg = _ffmpeg_bin() + if not ffmpeg: + return None + try: + proc = await asyncio.create_subprocess_exec( + ffmpeg, "-v", "quiet", "-nostdin", + "-i", str(path), + "-f", "s16le", + "-acodec", "pcm_s16le", + "-ac", "1", + "-ar", str(sr), + "-", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except OSError: + return None + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=120) + except asyncio.TimeoutError: + proc.kill() + return None + if proc.returncode != 0: + return None + return stdout + + +def _levels_from_samples(samples, width: int) -> list[float]: + import numpy as np + if samples.size == 0: + return [] + if samples.size < width: + pad = width - samples.size + samples = np.concatenate([samples, np.zeros(pad, dtype=samples.dtype)]) + bucket = max(1, samples.size // width) + trimmed = samples[: bucket * width] + peaks = trimmed.reshape(width, bucket).max(axis=1) + peak_max = float(peaks.max()) if peaks.size else 0.0 + if peak_max <= 0: + return [0.0] * width + return (peaks / peak_max).tolist() + + +def compute_levels(path: Path | str, width: int = 120) -> list[float]: + """Decode audio and return per-column peak levels normalized to [0, 1]. + + Blocking — for use in threads/executors. See compute_levels_async. + """ + width = max(8, int(width)) + try: + import numpy as np + except Exception: + return [] + raw = _decode_pcm(Path(path)) + if raw: + samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) + samples = np.abs(samples) / 32768.0 + else: + try: + import soundfile as sf + data, _sr = sf.read(str(path), dtype="float32", always_2d=False) + if data.ndim > 1: + data = data.mean(axis=1) + samples = np.abs(data) + except Exception: + return [] + return _levels_from_samples(samples, width) + + +async def compute_levels_async( + path: Path | str, width: int = 120 +) -> list[float]: + """Non-blocking variant: async ffmpeg, executor-bounded numpy.""" + width = max(8, int(width)) + try: + import numpy as np + except Exception: + return [] + raw = await _decode_pcm_async(Path(path)) + if not raw: + # Fall back to blocking soundfile in executor. + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor( + None, lambda: compute_levels(path, width) + ) + except Exception: + return [] + loop = asyncio.get_running_loop() + + def _work() -> list[float]: + samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) + samples = np.abs(samples) / 32768.0 + return _levels_from_samples(samples, width) + + return await loop.run_in_executor(None, _work) + + +def render_waveform(path: Path | str, width: int = 120) -> str: + """Single-line unicode waveform (legacy).""" + levels = compute_levels(path, width) + if not levels: + return "" + return "".join( + BLOCKS[int(min(len(BLOCKS) - 1, round(v * (len(BLOCKS) - 1))))] + for v in levels + ) + + +def overlay_cursor(waveform: str, position: float) -> str: + if not waveform: + return waveform + col = max(0, min(len(waveform) - 1, int(position * len(waveform)))) + return waveform[:col] + f"[reverse]{waveform[col]}[/reverse]" + waveform[col + 1 :] diff --git a/tui/widgets/__init__.py b/tui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/widgets/command_input.py b/tui/widgets/command_input.py new file mode 100644 index 0000000..37d73dd --- /dev/null +++ b/tui/widgets/command_input.py @@ -0,0 +1,21 @@ +"""Free-text palette input. No forced ``/`` prefix. + +Leading ``/`` is allowed and acts as a hint to the palette to filter +to commands only — see ``tui/palette.py``. +""" +from __future__ import annotations + +from textual.widgets import Input + + +class CommandInput(Input): + DEFAULT_CSS = """ + CommandInput { + border: tall $accent; + height: 3; + } + """ + + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("placeholder", "search · type / for commands only") + super().__init__(*args, **kwargs) diff --git a/tui/widgets/progress_bar.py b/tui/widgets/progress_bar.py new file mode 100644 index 0000000..60b9e1a --- /dev/null +++ b/tui/widgets/progress_bar.py @@ -0,0 +1,21 @@ +"""Inline text progress bar widget for jobs.""" +from __future__ import annotations + +from textual.reactive import reactive +from textual.widget import Widget + + +class JobProgress(Widget): + DEFAULT_CSS = """ + JobProgress { height: 1; padding: 0 1; } + """ + + progress: reactive[float] = reactive(0.0) + label: reactive[str] = reactive("") + + def render(self) -> str: + width = max(10, self.size.width - 20) + filled = int(max(0.0, min(1.0, self.progress)) * width) + bar = "█" * filled + "░" * (width - filled) + pct = int(self.progress * 100) + return f"{bar} {pct:3d}% {self.label}" diff --git a/tui/widgets/segment_list.py b/tui/widgets/segment_list.py new file mode 100644 index 0000000..a858988 --- /dev/null +++ b/tui/widgets/segment_list.py @@ -0,0 +1,87 @@ +"""Scrollable list of transcript segments.""" +from __future__ import annotations + +from textual.binding import Binding +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +def _fmt_ts(seconds: float) -> str: + s = int(seconds) + h, rem = divmod(s, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h:d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" + + +class SegmentList(OptionList): + BINDINGS = [ + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + Binding("g,g", "first", show=False), + Binding("G", "last", show=False), + Binding("n", "next_speaker", "Next speaker"), + Binding("N", "prev_speaker", "Prev speaker"), + ] + + DEFAULT_CSS = """ + SegmentList { height: 1fr; border: tall $panel; } + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.segments: list[dict] = [] + + def load(self, segments: list[dict]) -> None: + self.clear_options() + self.segments = segments or [] + for i, seg in enumerate(self.segments): + self.add_option(Option(self._format_row(seg), id=str(i))) + + def _format_row(self, seg: dict) -> str: + ts = _fmt_ts(float(seg.get("start", 0))) + speaker = seg.get("speaker") or seg.get("speaker_label") or "" + text = (seg.get("text") or "").strip() + prefix = f"[dim]{ts}[/dim]" + if speaker: + prefix += f" [bold]{speaker}[/bold]" + return f"{prefix} {text}" + + def selected_segment(self) -> dict | None: + idx = self.highlighted + if idx is None or not (0 <= idx < len(self.segments)): + return None + return self.segments[idx] + + def action_first(self) -> None: + if self.option_count: + self.highlighted = 0 + + def action_last(self) -> None: + if self.option_count: + self.highlighted = self.option_count - 1 + + def action_next_speaker(self) -> None: + idx = (self.highlighted or 0) + 1 + cur = self._speaker_at(self.highlighted or 0) + while idx < len(self.segments): + if self._speaker_at(idx) != cur: + self.highlighted = idx + return + idx += 1 + + def action_prev_speaker(self) -> None: + idx = (self.highlighted or 0) - 1 + cur = self._speaker_at(self.highlighted or 0) + while idx >= 0: + if self._speaker_at(idx) != cur: + self.highlighted = idx + return + idx -= 1 + + def _speaker_at(self, i: int) -> str: + if 0 <= i < len(self.segments): + s = self.segments[i] + return s.get("speaker") or s.get("speaker_label") or "" + return "" diff --git a/tui/widgets/status_bar.py b/tui/widgets/status_bar.py new file mode 100644 index 0000000..934c2a2 --- /dev/null +++ b/tui/widgets/status_bar.py @@ -0,0 +1,56 @@ +"""Bottom status bar: connection state, hints, transient messages.""" +from __future__ import annotations + +from textual.reactive import reactive +from textual.widget import Widget + + +class StatusBar(Widget): + """Single-line status bar at the bottom of the app.""" + + DEFAULT_CSS = """ + StatusBar { + height: 1; + background: $panel; + color: $text-muted; + padding: 0 1; + } + StatusBar.-error { background: $error; color: $text; } + StatusBar.-ok { background: $panel; } + StatusBar.-leader { background: $primary; color: $text; } + """ + + connection: reactive[str] = reactive("connecting") + message: reactive[str] = reactive("") + hint: reactive[str] = reactive("Space leader · / palette · ? help") + leader_hint: reactive[str] = reactive("") + + def render(self) -> str: + if self.leader_hint: + return f"LEADER · {self.leader_hint}" + parts = [f"● {self.connection}"] + if self.message: + parts.append(self.message) + parts.append(self.hint) + return " · ".join(parts) + + def set_connection(self, state: str, ok: bool = True) -> None: + self.connection = state + self.set_class(not ok, "-error") + self.set_class(ok, "-ok") + + def flash(self, msg: str) -> None: + self.message = msg + self.set_timer(4.0, lambda: setattr(self, "message", "")) + + def show_chord_hints(self, hints: list[tuple[str, str]]) -> None: + pretty_key = {"question_mark": "?"} + rendered = " · ".join( + f"{pretty_key.get(k, k)}={lbl}" for k, lbl in hints + ) + self.leader_hint = rendered + self.set_class(True, "-leader") + + def clear_chord_hints(self) -> None: + self.leader_hint = "" + self.set_class(False, "-leader") diff --git a/tui/widgets/waveform_view.py b/tui/widgets/waveform_view.py new file mode 100644 index 0000000..42b6021 --- /dev/null +++ b/tui/widgets/waveform_view.py @@ -0,0 +1,60 @@ +"""Multi-row animated waveform widget.""" +from __future__ import annotations + +from rich.console import RenderableType +from rich.segment import Segment +from rich.style import Style +from rich.text import Text +from textual.reactive import reactive +from textual.widget import Widget + + +# Web-UI palette gradient (low → high amplitude). +GRADIENT = ["#60a5fa", "#a78bfa", "#6c63ff", "#db2777", "#f472b6"] +PLAYED_GRADIENT = ["#475569", "#64748b", "#94a3b8"] + + +def _color_for(level: float, played: bool) -> str: + palette = PLAYED_GRADIENT if played else GRADIENT + idx = min(len(palette) - 1, max(0, int(level * len(palette)))) + return palette[idx] + + +class WaveformView(Widget): + """Renders levels as a vertically-stacked block waveform with animated cursor.""" + + DEFAULT_CSS = """ + WaveformView { + height: 7; + padding: 0 1; + background: $panel; + } + """ + + levels: reactive[list[float]] = reactive(list, layout=True) + position: reactive[float] = reactive(0.0) + rows: int = 5 + + def render(self) -> RenderableType: + if not self.levels: + return Text("(no waveform — playback will still work)", style="dim") + width = len(self.levels) + cursor_col = max(0, min(width - 1, int(self.position * width))) + text = Text() + for row in range(self.rows, 0, -1): + threshold = row / self.rows + half = threshold - 1 / (2 * self.rows) + for col, level in enumerate(self.levels): + played = col <= cursor_col + if level >= threshold: + char = "█" + elif level >= half: + char = "▄" + else: + char = " " + style = Style(color=_color_for(level, played)) + if col == cursor_col: + style = style + Style(bgcolor="#ede9ff", color="#0f172a") + text.append(char, style=style) + text.append("\n") + return text From 8fa48933bc630cc3fa451fce30a6f4c623d620da Mon Sep 17 00:00:00 2001 From: Simone Celestino Date: Sun, 17 May 2026 22:23:29 +0200 Subject: [PATCH 02/13] Refactor library screen and add new features - Updated LibraryPanel to improve status display with icons and colors. - Enhanced duration formatting and date display in the library. - Introduced new methods for formatting status and tags. - Modified LibraryScreen to include a context hint and command bar. - Added LogsScreen for live server log tailing with color-coded log levels. - Created SearchScreen for full-text search results with improved UI. - Revamped SettingsPanel for better organization and added server settings. - Improved TranscriptScreen with enhanced metadata display and speaker colors. - Removed WelcomeScreen as it was deemed unnecessary. - Introduced shared chrome components: TitleBar, ContextHint, and CommandBar. - Updated SegmentList to include speaker colors and improved formatting. - Enhanced StatusBar for better connection status display and message handling. --- tui/api.py | 5 +- tui/app.py | 156 +++++++++++++++------- tui/commands.py | 43 +++---- tui/palette.py | 42 ++++-- tui/screens/import_.py | 148 +++++++++++++++++++++ tui/screens/{jobs.py => job_detail.py} | 4 +- tui/screens/jobs_list.py | 171 +++++++++++++++++++++++++ tui/screens/library.py | 135 ++++++++++++------- tui/screens/logs.py | 112 ++++++++++++++++ tui/screens/search.py | 134 +++++++++++++++++++ tui/screens/settings.py | 132 ++++++++++++++----- tui/screens/transcript.py | 82 ++++++++---- tui/screens/welcome.py | 141 -------------------- tui/widgets/chrome.py | 114 +++++++++++++++++ tui/widgets/segment_list.py | 23 +++- tui/widgets/status_bar.py | 25 ++-- 16 files changed, 1115 insertions(+), 352 deletions(-) create mode 100644 tui/screens/import_.py rename tui/screens/{jobs.py => job_detail.py} (97%) create mode 100644 tui/screens/jobs_list.py create mode 100644 tui/screens/logs.py create mode 100644 tui/screens/search.py delete mode 100644 tui/screens/welcome.py create mode 100644 tui/widgets/chrome.py diff --git a/tui/api.py b/tui/api.py index b2ef88a..1ebd57a 100644 --- a/tui/api.py +++ b/tui/api.py @@ -152,11 +152,14 @@ async def remove_tag(self, recording_id: str, tag_id: int) -> dict: f"/api/recordings/{recording_id}/tags/{tag_id}" ) - async def search(self, q: str, limit: int = 50, offset: int = 0) -> dict: + async def search(self, q: str, limit: int = 50, offset: int = 0) -> list: return await self._get("/api/search", q=q, limit=limit, offset=offset) # --- jobs -------------------------------------------------------- + async def jobs(self) -> dict: + return await self._get("/api/jobs") + async def job_result(self, job_id: str) -> dict: return await self._get(f"/api/jobs/{job_id}/result") diff --git a/tui/app.py b/tui/app.py index de8e727..94f9f15 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,7 +1,8 @@ """Main Textual App for AmicoScript TUI. -Modeless, palette-driven. No tabs. Leader key (Space) arms per-screen -chord maps; ``/`` or ``ctrl+k`` opens the unified fuzzy palette. +Modeless, palette-driven. Lands directly on Library. Leader key (Space) +arms per-screen chord maps; ``/`` or ``ctrl+k`` opens the unified fuzzy +palette. """ from __future__ import annotations @@ -16,52 +17,108 @@ from .config import Config from .leader import LeaderDispatcher from .palette import Palette -from .screens.welcome import WelcomeScreen from .server import ServerManager +# Mockup palette (see amicoscript_tui_mockups.html). +COLOR_BG = "#0c0e1a" +COLOR_SURFACE = "#12152a" +COLOR_SURFACE2 = "#1a1d35" +COLOR_BORDER = "#4a47c0" +COLOR_BORDER_DIM = "#2a2860" +COLOR_BORDER_BRIGHT = "#7c79f0" +COLOR_TEXT = "#dde1ff" +COLOR_TEXT_DIM = "#6b6e9a" +COLOR_TEXT_MUTED = "#3a3d6a" +COLOR_PURPLE = "#7c79f0" +COLOR_PURPLE_BG = "#1e1b52" +COLOR_PURPLE_SEL = "#2d2a7a" +COLOR_AMBER = "#f59e0b" +COLOR_GREEN = "#22c55e" +COLOR_RED = "#ef4444" +COLOR_TEAL = "#2dd4bf" + + class AmicoTUI(App): """AmicoScript terminal interface.""" - CSS = """ - $primary: #6c63ff; - $accent: #a78bfa; - $surface: #0f172a; - $panel: #1e293b; - $boost: #2a3548; - $text: #e2e8f0; - $text-muted: #94a3b8; - $success: #10b981; - $warning: #f59e0b; - $error: #ef4444; - - Screen { background: $surface; color: $text; } - Header { background: $primary; color: $text; } - DataTable > .datatable--header { - background: $panel; - color: $accent; - } - DataTable > .datatable--cursor { - background: $primary; - color: $text; - } - DataTable > .datatable--hover { - background: $boost; - } - OptionList { background: $surface; color: $text; } - OptionList > .option-list--option-highlighted { - background: $primary; - color: $text; - } - Input { - background: $panel; - color: $text; - border: tall $primary; - } - Button { - background: $primary; - color: $text; - } + CSS = f""" + $primary: {COLOR_PURPLE}; + $accent: {COLOR_PURPLE}; + $surface: {COLOR_BG}; + $panel: {COLOR_SURFACE}; + $boost: {COLOR_SURFACE2}; + $text: {COLOR_TEXT}; + $text-muted: {COLOR_TEXT_DIM}; + $success: {COLOR_GREEN}; + $warning: {COLOR_AMBER}; + $error: {COLOR_RED}; + + Screen {{ + background: {COLOR_BG}; + color: {COLOR_TEXT}; + }} + Header {{ + background: {COLOR_PURPLE_BG}; + color: {COLOR_PURPLE}; + }} + Footer {{ + background: {COLOR_SURFACE}; + color: {COLOR_TEXT_DIM}; + }} + DataTable {{ + background: {COLOR_BG}; + color: {COLOR_TEXT}; + }} + DataTable > .datatable--header {{ + background: {COLOR_SURFACE}; + color: {COLOR_TEXT_DIM}; + text-style: none; + }} + DataTable > .datatable--cursor {{ + background: {COLOR_PURPLE_SEL}; + color: {COLOR_TEXT}; + }} + DataTable > .datatable--hover {{ + background: {COLOR_SURFACE2}; + }} + OptionList {{ + background: {COLOR_BG}; + color: {COLOR_TEXT}; + border: none; + }} + OptionList > .option-list--option-highlighted {{ + background: {COLOR_PURPLE_SEL}; + color: {COLOR_TEXT}; + }} + OptionList > .option-list--option-hover {{ + background: {COLOR_SURFACE2}; + }} + Input {{ + background: {COLOR_SURFACE2}; + color: {COLOR_TEXT}; + border: tall {COLOR_BORDER_DIM}; + }} + Input:focus {{ + border: tall {COLOR_BORDER_BRIGHT}; + }} + Button {{ + background: {COLOR_PURPLE_BG}; + color: {COLOR_PURPLE}; + border: tall {COLOR_BORDER}; + }} + Button:hover {{ + background: {COLOR_PURPLE_SEL}; + }} + Button.-primary {{ + background: {COLOR_PURPLE}; + color: {COLOR_TEXT}; + }} + Log {{ + background: #080a14; + color: {COLOR_TEXT_DIM}; + border: tall {COLOR_BORDER_DIM}; + }} """ BINDINGS = [ @@ -83,7 +140,8 @@ def __init__(self, cfg: Config, server: ServerManager) -> None: self.leader = LeaderDispatcher(self) def on_mount(self) -> None: - self.push_screen(WelcomeScreen()) + from .screens.library import LibraryScreen + self.push_screen(LibraryScreen()) self.run_worker(self._health_loop(), exclusive=True, name="health") async def on_unmount(self) -> None: @@ -119,7 +177,6 @@ def on_key(self, event: Key) -> None: # --- actions -------------------------------------------------------- def action_palette(self, seed: str = "") -> None: - # Avoid stacking duplicate palettes. if isinstance(self.screen, Palette): return self.push_screen(Palette(initial=seed)) @@ -133,14 +190,17 @@ async def on_paste(self, event) -> None: text = text[7:] from pathlib import Path p = Path(text) - if p.is_file() and p.suffix.lower() in { - ".mp3", ".wav", ".m4a", ".flac", ".ogg", ".opus", - ".mp4", ".mkv", ".webm", ".mov", ".aac", - }: + if p.is_file() and p.suffix.lower() in AUDIO_EXTS: self.notify(f"dropped: {p.name} — transcribing") await run_command(self, f"transcribe {shquote(str(p))}") +AUDIO_EXTS = { + ".mp3", ".wav", ".m4a", ".flac", ".ogg", ".opus", + ".mp4", ".mkv", ".webm", ".mov", ".aac", +} + + def shquote(s: str) -> str: if " " in s or "'" in s: return '"' + s.replace('"', '\\"') + '"' diff --git a/tui/commands.py b/tui/commands.py index ec7aa83..390d79a 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -76,8 +76,8 @@ async def _transcribe(app, args): result = await app.api.transcribe_file(path) job_id = result.get("job_id") or result.get("id") if job_id: - from .screens.jobs import JobScreen - app.push_screen(JobScreen(job_id)) + from .screens.job_detail import JobDetailScreen + app.push_screen(JobDetailScreen(job_id)) else: app.notify(f"submitted: {result}") @@ -90,8 +90,8 @@ async def _transcribe_url(app, args): result = await app.api.transcribe_url(args[0]) jobs = result.get("jobs") or ([result] if result.get("job_id") else []) if jobs: - from .screens.jobs import JobScreen - app.push_screen(JobScreen(jobs[0].get("job_id") or jobs[0].get("id"))) + from .screens.job_detail import JobDetailScreen + app.push_screen(JobDetailScreen(jobs[0].get("job_id") or jobs[0].get("id"))) else: app.notify(f"submitted: {result}") @@ -102,16 +102,8 @@ async def _search(app, args): app.notify("usage: /search ") return q = " ".join(args) - data = await app.api.search(q) - hits = data.get("results") or data.get("hits") or [] - if not hits: - app.notify("no results") - return - lines = [ - f"{h.get('recording_id', '')[:8]} {h.get('snippet') or h.get('text', '')[:60]}" - for h in hits[:20] - ] - app.notify("\n".join(lines), timeout=10) + from .screens.search import SearchScreen + app.push_screen(SearchScreen(q)) @command("export", "export ") @@ -176,11 +168,8 @@ async def _tag(app, args): @command("logs", "show server log buffer") async def _logs(app, args): - if not app.server or not app.server.logs: - app.notify("no logs captured") - return - tail = list(app.server.logs)[-40:] - app.notify("\n".join(tail), timeout=12) + from .screens.logs import LogsScreen + app.push_screen(LogsScreen()) @command("settings", "open settings screen") @@ -189,6 +178,13 @@ async def _settings(app, args): app.push_screen(SettingsScreen()) +@command("import", "browse the filesystem to pick a file to transcribe") +async def _import(app, args): + from .screens.import_ import ImportScreen + start = Path(args[0]).expanduser() if args else None + app.push_screen(ImportScreen(start)) + + @command("library", "open the recordings library") async def _library(app, args): from .screens.library import LibraryScreen @@ -197,17 +193,10 @@ async def _library(app, args): @command("jobs", "open the active-jobs list") async def _jobs(app, args): - from .screens.library import JobsListScreen + from .screens.jobs_list import JobsListScreen app.push_screen(JobsListScreen()) -@command("welcome", "return to the welcome screen") -async def _welcome(app, args): - # Pop everything back to the welcome screen. - while len(app.screen_stack) > 1: - app.pop_screen() - - @command("analyze", "pick a recording and run analysis") async def _analyze(app, args): """Three forms: diff --git a/tui/palette.py b/tui/palette.py index 8c9e6d5..f3bcc31 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -67,33 +67,48 @@ class Palette(ModalScreen): DEFAULT_CSS = """ Palette { align: center middle; - background: transparent; + background: rgba(12,14,26,0.85); } #box { - width: 80%; - max-width: 100; + width: 70%; + max-width: 90; height: auto; padding: 0; - background: $boost; - border-left: thick $primary; + background: #12152a; + border: tall #4a47c0; + } + #header { + height: 1; + padding: 0 2; + background: #12152a; + color: #6b6e9a; + border-bottom: solid #2a2860; } #suggestions { height: auto; max-height: 14; - background: $boost; + background: #12152a; border: none; + color: #dde1ff; + } + #suggestions > .option-list--option-highlighted { + background: #2d2a7a; + color: #dde1ff; } CommandInput { border: none; - background: $boost; + background: #1a1d35; + color: #dde1ff; height: 1; - padding: 0 1; + padding: 0 2; + border-top: solid #4a47c0; } #hint { height: 1; - color: $text-muted; - background: $boost; - padding: 0 1; + color: #6b6e9a; + background: #12152a; + padding: 0 2; + border-top: solid #2a2860; } """ @@ -131,8 +146,9 @@ def __init__( def compose(self): with Vertical(id="box"): + yield Static("command palette", id="header") yield OptionList(id="suggestions") - yield CommandInput(placeholder="search …") + yield CommandInput(placeholder="/") yield Static("", id="hint") async def on_mount(self) -> None: @@ -325,7 +341,7 @@ def _refresh(self, raw: str) -> None: lst.clear_options() for e in self._visible: lst.add_option(Option( - f"[b]{e.display}[/b] [dim]{e.subtitle}[/dim]", + f"[b #7c79f0]{e.display:<14}[/] [#6b6e9a]{e.subtitle}[/]", id=e.key, )) if self._visible: diff --git a/tui/screens/import_.py b/tui/screens/import_.py new file mode 100644 index 0000000..282da0f --- /dev/null +++ b/tui/screens/import_.py @@ -0,0 +1,148 @@ +"""File-browser screen: pick an audio/video file from the local filesystem. + +Triggered by ``/import [start_path]``. Uses Textual's DirectoryTree but +restricts file selection to known audio/video extensions. +""" +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Iterable + +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import DirectoryTree, Input, Static + +from ..app import AUDIO_EXTS, shquote +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +class FilteredDirectoryTree(DirectoryTree): + """Hide hidden dirs; only show audio/video files + directories.""" + + def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: # type: ignore[override] + for p in paths: + try: + if p.name.startswith("."): + continue + if p.is_dir(): + yield p + elif p.suffix.lower() in AUDIO_EXTS: + yield p + except OSError: + continue + + +class ImportScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + Binding("h", "go_home", "Home"), + Binding("backspace", "go_up", "Up"), + ] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "q": ("Back", "/quit"), + } + + DEFAULT_CSS = """ + ImportScreen { layout: vertical; } + #pathline { + height: 3; + padding: 0 2; + background: #12152a; + color: #dde1ff; + border-bottom: solid #2a2860; + } + #pathline Static { + width: 6; + height: 3; + content-align: left middle; + color: #7c79f0; + } + #pathline Input { + height: 3; + background: #12152a; + color: #dde1ff; + border: none; + } + DirectoryTree { + height: 1fr; + background: #0c0e1a; + color: #dde1ff; + } + """ + + def __init__(self, start: Path | None = None) -> None: + super().__init__() + self.start_path = (start or Path.home()).expanduser().resolve() + if not self.start_path.exists(): + self.start_path = Path.home() + self.title = "Import" + + def compose(self): + yield TitleBar(id="titlebar") + with Horizontal(id="pathline"): + yield Static("path:", id="pathlabel") + yield Input(value=str(self.start_path), id="pathinput") + with Vertical(): + yield FilteredDirectoryTree(str(self.start_path), id="tree") + yield ContextHint( + "↑↓ navigate · ↵ enter dir or pick file · h home · backspace up · Esc cancel", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + self.query_one(DirectoryTree).focus() + + async def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "pathinput": + return + p = Path(event.value).expanduser() + if not p.is_dir(): + self.app.notify(f"not a directory: {p}") + return + self._reload(p) + + def _reload(self, p: Path) -> None: + try: + tree = self.query_one(DirectoryTree) + tree.path = p # type: ignore[assignment] + tree.reload() + except Exception: + new_tree = FilteredDirectoryTree(str(p), id="tree") + old = self.query_one(DirectoryTree) + old.remove() + self.mount(new_tree) + self.query_one("#pathinput", Input).value = str(p) + self.start_path = p + + def action_go_home(self) -> None: + self._reload(Path.home()) + + def action_go_up(self) -> None: + self._reload(self.start_path.parent) + + def action_pop(self) -> None: + self.app.pop_screen() + + async def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + p = Path(event.path) + if p.suffix.lower() not in AUDIO_EXTS: + self.app.notify(f"unsupported: {p.suffix}", severity="warning") + return + from ..commands import run_command + self.app.notify(f"importing: {p.name}") + self.app.pop_screen() + await run_command(self.app, f"transcribe {shquote(str(p))}") diff --git a/tui/screens/jobs.py b/tui/screens/job_detail.py similarity index 97% rename from tui/screens/jobs.py rename to tui/screens/job_detail.py index 8a159a2..43c8386 100644 --- a/tui/screens/jobs.py +++ b/tui/screens/job_detail.py @@ -16,7 +16,7 @@ from ..app import AmicoTUI -class JobScreen(Screen): +class JobDetailScreen(Screen): BINDINGS = [ Binding("escape", "pop", "Back"), Binding("q", "pop", "Back"), @@ -24,7 +24,7 @@ class JobScreen(Screen): ] DEFAULT_CSS = """ - JobScreen { layout: vertical; } + JobDetailScreen { layout: vertical; } #title { padding: 0 1; height: 1; } Log { height: 1fr; border: tall $panel; } """ diff --git a/tui/screens/jobs_list.py b/tui/screens/jobs_list.py new file mode 100644 index 0000000..bdfe20f --- /dev/null +++ b/tui/screens/jobs_list.py @@ -0,0 +1,171 @@ +"""Active-jobs list with live progress bars.""" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import DataTable + +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +def _fmt_started(ts: float) -> str: + if not ts: + return "" + return time.strftime("%H:%M:%S", time.localtime(ts)) + + +def _progress_bar(pct: float, width: int = 24, color: str = "#7c79f0") -> Text: + p = max(0.0, min(1.0, pct)) + filled = int(round(p * width)) + out = Text() + out.append("█" * filled, style=color) + out.append("░" * (width - filled), style="#2a2860") + out.append(f" {int(p * 100):3d}%", style=color) + return out + + +class JobsPanel(Widget): + BINDINGS = [ + Binding("r", "refresh", "Refresh"), + Binding("j", "cursor_down", show=False), + Binding("k", "cursor_up", show=False), + Binding("c", "cancel", "Cancel"), + ] + + DEFAULT_CSS = """ + JobsPanel { layout: vertical; height: 1fr; } + DataTable { height: 1fr; background: #0c0e1a; } + """ + + def __init__(self, on_count=None, **kwargs) -> None: + super().__init__(**kwargs) + self.table: DataTable | None = None + self.job_ids: list[str] = [] + self.on_count = on_count + + def compose(self): + with Vertical(): + yield DataTable(cursor_type="row", zebra_stripes=False) + + def on_mount(self) -> None: + self.table = self.query_one(DataTable) + self.table.add_columns("FILE", "STATUS", "STARTED", "PROGRESS", "ACTION") + self.refresh_jobs() + self.set_interval(2.0, self.refresh_jobs) + + def action_refresh(self) -> None: + self.refresh_jobs() + + def action_cursor_down(self) -> None: + if self.table: + self.table.action_cursor_down() + + def action_cursor_up(self) -> None: + if self.table: + self.table.action_cursor_up() + + def action_cancel(self) -> None: + if not self.table or self.table.row_count == 0: + return + idx = self.table.cursor_row + if not (0 <= idx < len(self.job_ids)): + return + from ..commands import run_command + self.run_worker(run_command(self.app, f"cancel {self.job_ids[idx]}")) + + def refresh_jobs(self) -> None: + self.run_worker(self._load(), exclusive=True) + + async def _load(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.jobs() + except Exception as e: + self.app.notify(f"jobs load failed: {e}", severity="error") + return + rows = data.get("jobs", []) if isinstance(data, dict) else [] + assert self.table is not None + self.table.clear() + self.job_ids.clear() + for j in rows: + jid = str(j.get("id", "")) + fname = j.get("filename") or j.get("source_url") or jid + status = j.get("status", "") + pct = 0.0 + try: + pct = float(j.get("progress") or 0.0) + except (TypeError, ValueError): + pass + if pct > 1.0: + pct = pct / 100.0 + color = "#22c55e" if status in ("done", "completed") else "#f59e0b" + self.table.add_row( + Text(f"⠸ {fname}", style="#f59e0b"), + Text(status, style=color), + Text(_fmt_started(j.get("created_at") or 0), style="#6b6e9a"), + _progress_bar(pct, width=24, color=color), + Text("/cancel", style="#ef4444"), + ) + self.job_ids.append(jid) + if self.on_count: + self.on_count(len(rows)) + + +class JobsListScreen(Screen): + """List of active jobs — replaces old filtered-library Jobs view.""" + + BINDINGS = [Binding("escape", "pop", "Back")] + + leader_chords = { + "l": ("Library", "/library"), + "s": ("Settings", "/settings"), + "i": ("Import", "/import"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + JobsListScreen { layout: vertical; } + JobsPanel { height: 1fr; } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "Jobs" + + def compose(self): + yield TitleBar(id="titlebar") + with Vertical(): + yield JobsPanel(on_count=self._on_count, id="jobs_panel") + yield ContextHint( + "0 active · c cancel selected · /cancel · ↵ open detail", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + try: + self.query_one(DataTable).focus() + except Exception: + pass + + def _on_count(self, n: int) -> None: + try: + self.query_one("#ctxhint", ContextHint).set_text( + f"{n} active · c cancel selected · /cancel · ↵ open detail" + ) + except Exception: + pass + + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/library.py b/tui/screens/library.py index 741848b..88aa200 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -4,38 +4,38 @@ from datetime import datetime from typing import TYPE_CHECKING +from rich.text import Text from textual.binding import Binding from textual.containers import Vertical from textual.screen import Screen from textual.widget import Widget -from textual.widgets import DataTable, Footer, Header +from textual.widgets import DataTable from ..clipboard import copy_to_clipboard +from ..widgets.chrome import CommandBar, ContextHint, TitleBar if TYPE_CHECKING: from ..app import AmicoTUI -STATUS_ICON = { - "pending": "○", - "queued": "○", - "transcribing": "◐", - "diarizing": "◑", - "done": "●", - "completed": "●", - "error": "✗", +STATUS_DISPLAY = { + "pending": ("○", "queued", "#6b6e9a"), + "queued": ("○", "queued", "#6b6e9a"), + "transcribing":("⠸", "proc", "#f59e0b"), + "diarizing": ("⠴", "diariz", "#f59e0b"), + "done": ("●", "done", "#22c55e"), + "completed": ("●", "done", "#22c55e"), + "error": ("✗", "error", "#ef4444"), } def _fmt_duration(seconds): if not seconds: - return "--:--" + return "--" s = int(seconds) h, rem = divmod(s, 3600) - m, s = divmod(rem, 60) - if h: - return f"{h:d}:{m:02d}:{s:02d}" - return f"{m:02d}:{s:02d}" + m, _ = divmod(rem, 60) + return f"{h:d}h {m:02d}m" def _fmt_date(value): @@ -43,19 +43,41 @@ def _fmt_date(value): return "" if isinstance(value, (int, float)): try: - return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M") + return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d") except (ValueError, OSError): return "" try: return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime( - "%Y-%m-%d %H:%M" + "%Y-%m-%d" ) except ValueError: - return value[:16] + return value[:10] + + +def _fmt_status(status: str) -> Text: + icon, label, color = STATUS_DISPLAY.get(status, ("·", status or "?", "#6b6e9a")) + return Text(f"{icon} {label}", style=color) + + +def _fmt_tags(tags) -> Text: + if not tags: + return Text("") + out = Text() + for i, t in enumerate(tags[:3]): + if isinstance(t, dict): + name = t.get("name", "") + color = t.get("color_code") or "#7c79f0" + else: + name = str(t) + color = "#7c79f0" + if i: + out.append(" ") + out.append(f"[{name}]", style=color) + return out class LibraryPanel(Widget): - """Recording list panel, embeddable in a tab.""" + """Recording list panel.""" BINDINGS = [ Binding("r", "refresh", "Refresh"), @@ -70,7 +92,7 @@ class LibraryPanel(Widget): DEFAULT_CSS = """ LibraryPanel { layout: vertical; height: 1fr; } - DataTable { height: 1fr; } + DataTable { height: 1fr; background: #0c0e1a; } """ def __init__( @@ -78,6 +100,7 @@ def __init__( status_filter: str | None = None, folder_id: str | None = None, tag_id: str | None = None, + on_loaded=None, *args, **kwargs, ) -> None: @@ -87,18 +110,18 @@ def __init__( self.status_filter = status_filter self.folder_id = folder_id self.tag_id = tag_id + self.on_loaded = on_loaded def compose(self): with Vertical(): - yield DataTable(cursor_type="row", zebra_stripes=True) + yield DataTable(cursor_type="row", zebra_stripes=False) def on_mount(self) -> None: self.table = self.query_one(DataTable) - self.table.add_columns("", "Name", "Duration", "Status", "Created") + self.table.add_columns("FILE", "DATE", "DUR", "MODEL", "TAGS", "STATUS") self.refresh_library() def on_show(self) -> None: - # Re-fetch when tab re-activated. if self.table is not None: self.refresh_library() @@ -134,7 +157,8 @@ def action_copy_name(self) -> None: if rec_id is None or self.table is None: return row = self.table.get_row_at(self.table.cursor_row) - name = str(row[1]) + name_cell = row[0] + name = name_cell.plain if isinstance(name_cell, Text) else str(name_cell) if copy_to_clipboard(name): self.app.notify(f"copied: {name}") @@ -169,17 +193,26 @@ async def _load(self) -> None: assert self.table is not None self.table.clear() self.row_keys.clear() + total_dur = 0.0 for r in items: - icon = STATUS_ICON.get(r.get("status", ""), "·") name = r.get("alias") or r.get("filename") or f"#{r.get('id')}" + model = r.get("model_size") or r.get("model") or "" + dur = r.get("duration") or 0 + try: + total_dur += float(dur or 0) + except (TypeError, ValueError): + pass self.table.add_row( - icon, - name, - _fmt_duration(r.get("duration")), - r.get("status", ""), - _fmt_date(r.get("created_at")), + Text(name, style="#dde1ff"), + Text(_fmt_date(r.get("created_at")), style="#6b6e9a"), + Text(_fmt_duration(dur), style="#6b6e9a"), + Text(model, style="#7c79f0"), + _fmt_tags(r.get("tags")), + _fmt_status(r.get("status", "")), ) self.row_keys.append(str(r["id"])) + if self.on_loaded: + self.on_loaded(len(items), total_dur) def _selected_id(self) -> str | None: if not self.table or self.table.row_count == 0: @@ -191,16 +224,16 @@ def _selected_id(self) -> str | None: class LibraryScreen(Screen): - """Full-screen library view. Pushed by leader chord or /library.""" + """Full-screen library view — default landing.""" BINDINGS = [ - Binding("escape", "pop", "Back"), + Binding("escape", "pop_if_stacked", "Back"), ] leader_chords = { "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), - "h": ("Welcome", "/welcome"), + "i": ("Import", "/import"), "q": ("Quit", "/quit"), } @@ -226,34 +259,38 @@ def __init__( def compose(self): from ..widgets.status_bar import StatusBar - yield Header(show_clock=False) + yield TitleBar(id="titlebar") with Vertical(): yield LibraryPanel( status_filter=self.status_filter, folder_id=self.folder_id, tag_id=self.tag_id, + on_loaded=self._on_loaded, id="library_panel", ) - yield StatusBar(id="statusbar") - yield Footer() + yield ContextHint( + "↑↓ navigate · ↵ open · /import · /export · /delete · /folder · /search", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") def on_mount(self) -> None: self.query_one(LibraryPanel).query_one(DataTable).focus() - def action_pop(self) -> None: - self.app.pop_screen() - + def _on_loaded(self, count: int, total_dur: float) -> None: + h = int(total_dur // 3600) + m = int((total_dur % 3600) // 60) + try: + self.query_one("#ctxhint", ContextHint).set_text( + f"{count} recordings · {h}h {m:02d}m total " + f"| ↑↓ navigate · ↵ open · /import · /export · /delete · /search" + ) + except Exception: + pass -class JobsListScreen(LibraryScreen): - """Library filtered to in-flight jobs.""" + def action_pop_if_stacked(self) -> None: + if len(self.app.screen_stack) > 1: + self.app.pop_screen() - leader_chords = { - "l": ("Library", "/library"), - "s": ("Settings", "/settings"), - "h": ("Welcome", "/welcome"), - "q": ("Quit", "/quit"), - } - def __init__(self) -> None: - super().__init__(status_filter="transcribing") - self.title = "Jobs" diff --git a/tui/screens/logs.py b/tui/screens/logs.py new file mode 100644 index 0000000..a002a04 --- /dev/null +++ b/tui/screens/logs.py @@ -0,0 +1,112 @@ +"""Live server log tail screen.""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Log + +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +LEVEL_RE = re.compile(r"\b(INFO|WARN(?:ING)?|ERROR|DEBUG|CRITICAL)\b") + + +class LogsScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + Binding("c", "clear", "Clear"), + ] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "q": ("Back", "/quit"), + } + + DEFAULT_CSS = """ + LogsScreen { layout: vertical; } + Log { + height: 1fr; + background: #080a14; + color: #6b6e9a; + border: none; + } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "Logs" + self._last_n = 0 + + def compose(self): + yield TitleBar(id="titlebar") + with Vertical(): + yield Log(id="loglines", highlight=False, max_lines=5000) + yield ContextHint( + "live tail · c clear · /logs filter · Esc close", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + self._refresh_all() + self.set_interval(0.5, self._poll) + + def _refresh_all(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + log = self.query_one(Log) + log.clear() + if not app.server or not app.server.logs: + log.write_line("(no captured logs — running in --no-server mode)") + return + lines = list(app.server.logs) + self._last_n = len(lines) + for line in lines: + log.write_line(self._style(line)) + + def _poll(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + if not app.server or not app.server.logs: + return + lines = list(app.server.logs) + if len(lines) <= self._last_n: + return + log = self.query_one(Log) + for line in lines[self._last_n:]: + log.write_line(self._style(line)) + self._last_n = len(lines) + + def _style(self, line: str) -> str: + m = LEVEL_RE.search(line) + if not m: + return line + level = m.group(1) + color = { + "INFO": "#2dd4bf", + "WARN": "#f59e0b", + "WARNING": "#f59e0b", + "ERROR": "#ef4444", + "CRITICAL": "#ef4444", + "DEBUG": "#6b6e9a", + }.get(level, "#6b6e9a") + # Log widget supports limited markup via highlight=False — strip styling. + # Return plain text; coloring via inline markup not supported in Log widget cleanly. + return line + + def action_clear(self) -> None: + self.query_one(Log).clear() + self._last_n = 0 + + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/search.py b/tui/screens/search.py new file mode 100644 index 0000000..a8cdad9 --- /dev/null +++ b/tui/screens/search.py @@ -0,0 +1,134 @@ +"""Full-text search results screen.""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.screen import Screen +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option + +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +def _convert_mark(snippet: str) -> str: + """Replace HTML spans with Rich amber-on-black style.""" + if not snippet: + return "" + snippet = re.sub( + r"(.*?)", + r"[on #f59e0b black]\1[/]", + snippet, + flags=re.DOTALL, + ) + return snippet + + +class SearchScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + Binding("enter", "open", show=False), + ] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "q": ("Back", "/quit"), + } + + DEFAULT_CSS = """ + SearchScreen { layout: vertical; } + #queryline { + height: 1; + padding: 0 2; + background: #12152a; + color: #dde1ff; + border-bottom: solid #2a2860; + } + #results { height: 1fr; background: #0c0e1a; } + """ + + def __init__(self, query: str) -> None: + super().__init__() + self.query_text = query + self.results: list[dict] = [] + self.title = "Search" + + def compose(self): + yield TitleBar(id="titlebar") + yield Static( + f"[#7c79f0]/search[/] [#dde1ff]{self.query_text}[/] [#6b6e9a]loading…[/]", + id="queryline", + ) + with Vertical(): + yield OptionList(id="results") + yield ContextHint( + "↑↓ navigate · ↵ open recording · /search · Esc close", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") + + def on_mount(self) -> None: + self.query_one(OptionList).focus() + self.run_worker(self._load(), exclusive=True) + + async def _load(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.search(self.query_text) + except Exception as e: + self.query_one("#queryline", Static).update( + f"[#ef4444]error: {e}[/]" + ) + return + if isinstance(data, dict): + rows = data.get("results") or data.get("hits") or [] + else: + rows = data or [] + self.results = rows + lst = self.query_one("#results", OptionList) + lst.clear_options() + files = set() + for i, r in enumerate(rows): + rid = str(r.get("recording_id") or r.get("id") or "") + files.add(rid) + snippet = _convert_mark(r.get("snippet") or r.get("text") or "") + label = ( + f"[#7c79f0]{rid[:8]}[/] " + f"[#dde1ff]{snippet}[/]" + ) + lst.add_option(Option(label, id=str(i))) + self.query_one("#queryline", Static).update( + f"[#7c79f0]/search[/] [#dde1ff]{self.query_text}[/] " + f"[#6b6e9a]{len(rows)} results across {len(files)} files[/]" + ) + if rows: + lst.highlighted = 0 + + def on_option_list_option_selected( + self, event: OptionList.OptionSelected + ) -> None: + self.action_open() + + def action_open(self) -> None: + lst = self.query_one("#results", OptionList) + idx = lst.highlighted + if idx is None or not (0 <= idx < len(self.results)): + return + rid = str(self.results[idx].get("recording_id") or self.results[idx].get("id") or "") + if not rid: + return + from .transcript import TranscriptScreen + self.app.push_screen(TranscriptScreen(rid)) + + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/settings.py b/tui/screens/settings.py index 224b2ed..f8b3bf0 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -1,39 +1,105 @@ -"""Settings panel: HF token + LLM config.""" +"""Settings: sectioned form (Model / Diarization / Output / Server).""" from __future__ import annotations from typing import TYPE_CHECKING from textual.binding import Binding -from textual.containers import Vertical +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Button, Footer, Header, Input, Label +from textual.widgets import Button, Input, Static + +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar if TYPE_CHECKING: from ..app import AmicoTUI +def _section(title: str) -> Static: + return Static(f"[b #6b6e9a]{title}[/]", classes="section-hdr") + + class SettingsPanel(Widget): DEFAULT_CSS = """ SettingsPanel { layout: vertical; height: 1fr; } - Label { padding: 1 2 0 2; color: $accent; } - Input { margin: 0 2; } - Button { margin: 1 2 0 2; } + VerticalScroll { height: 1fr; } + .section-hdr { + padding: 1 2 0 2; + height: 2; + color: #6b6e9a; + background: #0c0e1a; + border-top: solid #2a2860; + } + .setting-row { + height: 3; + padding: 0 2; + background: #0c0e1a; + border-bottom: solid #2a2860; + } + .setting-label { + width: 28; + height: 3; + content-align: left middle; + color: #6b6e9a; + } + .setting-row Input { + height: 3; + background: #1a1d35; + color: #dde1ff; + border: tall #2a2860; + } + .setting-row Input:focus { border: tall #7c79f0; } + #btnrow { + height: 3; + padding: 1 2; + background: #0c0e1a; + } """ def compose(self): - with Vertical(): - yield Label("Hugging Face token (for diarization)") - yield Input(id="hf", password=True, placeholder="hf_...") - yield Label("LLM base URL") - yield Input(id="llm_url", placeholder="http://localhost:11434") - yield Label("LLM model name") - yield Input(id="llm_model", placeholder="llama3.1") - yield Label("LLM API key (optional)") - yield Input(id="llm_key", password=True) - yield Button("Save", id="save", variant="primary") + with VerticalScroll(): + yield _section("MODEL") + with Horizontal(classes="setting-row"): + yield Static("Default model", classes="setting-label") + yield Input(id="model", placeholder="large-v3") + with Horizontal(classes="setting-row"): + yield Static("Device", classes="setting-label") + yield Input(id="device", placeholder="auto") + with Horizontal(classes="setting-row"): + yield Static("Compute type", classes="setting-label") + yield Input(id="compute", placeholder="float16") + + yield _section("DIARIZATION") + with Horizontal(classes="setting-row"): + yield Static("Hugging Face token", classes="setting-label") + yield Input(id="hf", password=True, placeholder="hf_…") + + yield _section("LLM") + with Horizontal(classes="setting-row"): + yield Static("Base URL", classes="setting-label") + yield Input(id="llm_url", placeholder="http://localhost:11434") + with Horizontal(classes="setting-row"): + yield Static("Model name", classes="setting-label") + yield Input(id="llm_model", placeholder="llama3.1") + with Horizontal(classes="setting-row"): + yield Static("API key", classes="setting-label") + yield Input(id="llm_key", password=True) + + yield _section("SERVER") + with Horizontal(classes="setting-row"): + yield Static("API URL", classes="setting-label") + yield Input(id="api_url", disabled=True) + + with Horizontal(id="btnrow"): + yield Button("Save", id="save", variant="primary") + yield Button("Reset defaults", id="reset") def on_mount(self) -> None: + try: + self.query_one("#api_url", Input).value = self.app.api.base_url + except Exception: + pass self.run_worker(self._load(), exclusive=True) async def _load(self) -> None: @@ -43,7 +109,6 @@ async def _load(self) -> None: self.query_one("#hf", Input).value = s.get("hf_token") or "" except Exception as e: self.app.notify(f"settings load failed: {e}", severity="error") - return try: llm = await app.api.llm_settings() self.query_one("#llm_url", Input).value = llm.get("base_url") or "" @@ -51,11 +116,22 @@ async def _load(self) -> None: self.query_one("#llm_key", Input).value = llm.get("api_key") or "" except Exception: pass + try: + mods = await app.api.models() + default = mods.get("default") or mods.get("current") or "" + if default: + self.query_one("#model", Input).value = str(default) + except Exception: + pass async def on_button_pressed(self, event: Button.Pressed) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + if event.button.id == "reset": + self.run_worker(self._load(), exclusive=True) + self.app.notify("reloaded from server") + return if event.button.id != "save": return - app: "AmicoTUI" = self.app # type: ignore[assignment] try: await app.api.save_settings(hf_token=self.query_one("#hf", Input).value) await app.api.save_llm_settings( @@ -69,15 +145,13 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: class SettingsScreen(Screen): - """Full-screen settings view.""" - BINDINGS = [Binding("escape", "pop", "Back")] leader_chords = { "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), - "h": ("Welcome", "/welcome"), - "q": ("Quit", "/quit"), + "i": ("Import", "/import"), + "q": ("Back", "/quit"), } DEFAULT_CSS = """ @@ -90,18 +164,12 @@ def __init__(self) -> None: self.title = "Settings" def compose(self): - from ..widgets.status_bar import StatusBar - yield Header(show_clock=False) + yield TitleBar(id="titlebar") with Vertical(): yield SettingsPanel(id="settings_panel") - yield StatusBar(id="statusbar") - yield Footer() - - def on_mount(self) -> None: - try: - self.query_one(SettingsPanel).query_one("#hf", Input).focus() - except Exception: - pass + yield ContextHint("tab to navigate fields · Save persists to backend", id="ctxhint") + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") def action_pop(self) -> None: self.app.pop_screen() diff --git a/tui/screens/transcript.py b/tui/screens/transcript.py index c1a13dc..7b03498 100644 --- a/tui/screens/transcript.py +++ b/tui/screens/transcript.py @@ -8,11 +8,12 @@ from textual.binding import Binding from textual.containers import Vertical from textual.screen import Screen -from textual.widgets import Header, OptionList, Static +from textual.widgets import OptionList, Static from ..clipboard import copy_to_clipboard from ..playback import Player from ..waveform import compute_levels_async +from ..widgets.chrome import CommandBar, ContextHint, TitleBar from ..widgets.segment_list import SegmentList from ..widgets.status_bar import StatusBar from ..widgets.waveform_view import WaveformView @@ -34,9 +35,29 @@ class TranscriptScreen(Screen): DEFAULT_CSS = """ TranscriptScreen { layout: vertical; } - #title { padding: 0 1; height: 1; color: $accent; } + #meta { + height: 1; + padding: 0 2; + background: #12152a; + color: #dde1ff; + border-bottom: solid #2a2860; + } + #legend { + height: 1; + padding: 0 2; + background: #0c0e1a; + color: #6b6e9a; + border-bottom: solid #2a2860; + } """ + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "q": ("Back", "/quit"), + } + def __init__(self, recording_id: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.recording_id = recording_id @@ -44,38 +65,41 @@ def __init__(self, recording_id: str, *args, **kwargs) -> None: self.player = Player() self.duration_s: float = 0.0 self._anim_timer = None + self.title = "Transcript" def compose(self): - yield Header(show_clock=False) + yield TitleBar(id="titlebar") + yield Static("loading…", id="meta") + yield Static("", id="legend") with Vertical(): - yield Static("loading...", id="title") yield WaveformView(id="wave") yield SegmentList(id="segments") - yield StatusBar(id="statusbar") + yield ContextHint( + "Space play · y copy seg · Y copy all · /export json|srt|txt|md · ^A analyze", + id="ctxhint", + ) + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") def on_mount(self) -> None: self.run_worker(self._load(), exclusive=True) - # Animate at ~15 fps. self._anim_timer = self.set_interval(1 / 15, self._tick, pause=False) async def _load(self) -> None: - """Fast path: title + transcript visible immediately. Waveform separate worker.""" app: "AmicoTUI" = self.app # type: ignore[assignment] - title = self.query_one("#title", Static) + meta = self.query_one("#meta", Static) + legend = self.query_one("#legend", Static) seg_list = self.query_one(SegmentList) status = self.query_one(StatusBar) try: rec = await app.api.recording(self.recording_id) except Exception as e: - title.update(f"error: {e}") + meta.update(f"[#ef4444]error: {e}[/]") return name = rec.get("alias") or rec.get("filename") or self.recording_id self.duration_s = float(rec.get("duration") or 0.0) - title.update( - f"[b]{name}[/b] · {self._fmt_dur(self.duration_s)} · " - f"status: {rec.get('status', '?')}" - ) + model = rec.get("model_size") or rec.get("model") or "" try: tdata = await app.api.transcript(self.recording_id) @@ -87,8 +111,29 @@ async def _load(self) -> None: seg_list.load(segs) except Exception as e: status.set_connection(f"transcript load failed: {e}", ok=False) + segs = [] + + speakers = sorted({(s.get("speaker") or s.get("speaker_label") or "") + for s in segs} - {""}) + word_count = sum(len((s.get("text") or "").split()) for s in segs) + meta.update( + f"[b #dde1ff]{name}[/] [#6b6e9a]· " + f"{self._fmt_dur(self.duration_s)} · {word_count:,} words · " + f"{len(speakers)} speakers · [/][#7c79f0]{model}[/]" + ) + if speakers: + chips = " ".join( + f"[{seg_list.speaker_color(sp)}]■[/] [#dde1ff]{sp}[/]" + for sp in speakers + ) + else: + chips = "[#6b6e9a]no speakers[/]" + legend.update( + f"{chips} " + f"[#6b6e9a]export:[/] [#7c79f0]/export json[/] " + f"[#7c79f0]/export srt[/] [#7c79f0]/export txt[/] [#7c79f0]/export md[/]" + ) - # Waveform + audio: separate non-blocking worker so the screen is usable now. self.run_worker(self._load_audio(), exclusive=False, name="audio") async def _load_audio(self) -> None: @@ -124,16 +169,11 @@ def on_unmount(self) -> None: except OSError: pass - # --- animation -------------------------------------------------- - def _tick(self) -> None: wave = self.query_one(WaveformView) if self.player.is_playing() and self.duration_s > 0: pos = self.player.position() wave.position = max(0.0, min(1.0, pos / self.duration_s)) - # else leave position as-is (last cursor stays) - - # --- segment Enter → play --------------------------------------- def on_option_list_option_selected( self, event: OptionList.OptionSelected @@ -143,8 +183,6 @@ def on_option_list_option_selected( return self._play_from(float(seg.get("start", 0.0))) - # --- actions ---------------------------------------------------- - def action_pop(self) -> None: self.app.pop_screen() @@ -181,8 +219,6 @@ def action_analyze(self) -> None: from ..palette import _open_analysis_type_picker _open_analysis_type_picker(self.app, self.recording_id) - # --- helpers ---------------------------------------------------- - def _play_from(self, offset_s: float) -> None: if not self._tmp_audio or not self._tmp_audio.exists(): self.query_one(StatusBar).flash("audio not loaded yet") diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py deleted file mode 100644 index bebb2a5..0000000 --- a/tui/screens/welcome.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Welcome screen: hints + leader chord landing. - -No tabs. Bare ``l``/``j``/``s`` are shortcuts. ``Space`` arms the -leader and the StatusBar shows next-key hints. -""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -from textual.binding import Binding -from textual.containers import Vertical -from textual.screen import Screen -from textual.widgets import Footer, Header, Static - -if TYPE_CHECKING: - from ..app import AmicoTUI - - -LOGO = "\n".join([ - " ▄▄▀▀▀▄▄ ", - " ▄▄▀ ▀█▄ ", - " ▄▀ ▄██▄ ▀▄", - " █ █ ▄ ▀███ █", - " ▀█▄█▀█▄▀▄████▄ █", - "▀█ ██ ██ ▀▄▄█▄▀ █", - " █ ▀▀ ██▄ █", - " ▀▄ ████ ▄▀", - " ▀▀▄ █▀ ▀▄▀ ", - " ▀▀▄▄▄▀▀ ", -]) - -HINTS = """\ -[b]AmicoScript[/b] · local-first transcription - -[b $accent]Space l[/] Library [b $accent]Space j[/] Jobs -[b $accent]Space s[/] Settings [b $accent]Space ?[/] Help -[b $accent]Space q[/] Quit [b $accent]/ · ^K[/] Palette - -Direct keys (welcome only): [b]l[/] [b]j[/] [b]s[/] -Drop an audio file onto the terminal to transcribe. -""" - - -class WelcomeScreen(Screen): - """Landing screen. Bare letters jump; Space arms leader chord.""" - - BINDINGS = [ - Binding("l", "go('/library')", show=False), - Binding("j", "go('/jobs')", show=False), - Binding("s", "go('/settings')", show=False), - Binding("question_mark", "help", "Help"), - Binding("q", "quit", "Quit"), - ] - - leader_chords = { - "l": ("Library", "/library"), - "j": ("Jobs", "/jobs"), - "s": ("Settings", "/settings"), - "question_mark": ("Help", "/help"), - "q": ("Quit", "/quit"), - } - - DEFAULT_CSS = """ - WelcomeScreen { layout: vertical; } - #main { - width: 100%; - height: 1fr; - align: center middle; - } - #stack { - width: auto; - height: auto; - } - #logo { - color: $primary; - text-style: bold; - content-align: center middle; - width: 60; - height: auto; - margin-bottom: 1; - } - #version { - color: $accent; - content-align: center middle; - height: 1; - width: 60; - margin-bottom: 1; - } - #hints { - height: auto; - width: 60; - padding: 1 2; - background: $panel; - border: tall $primary; - color: $text; - } - """ - - def __init__(self) -> None: - super().__init__() - self.title = "AmicoScript" - - def compose(self): - from textual.containers import Container - from ..widgets.status_bar import StatusBar - yield Header(show_clock=False) - with Container(id="main"): - with Vertical(id="stack"): - yield Static(LOGO, id="logo") - yield Static("local-first · privacy-focused · whisper", id="version") - yield Static(HINTS, id="hints") - yield StatusBar(id="statusbar") - yield Footer() - - def on_mount(self) -> None: - self.run_worker(self._fetch_version(), exclusive=True) - - async def _fetch_version(self) -> None: - app: "AmicoTUI" = self.app # type: ignore[assignment] - try: - v = await app.api.version() - ver = v.get("version") if isinstance(v, dict) else str(v) - if ver: - self.query_one("#version", Static).update( - f"v{ver} · local-first · privacy-focused · whisper" - ) - except Exception: - pass - - async def action_go(self, cmd: str) -> None: - from ..commands import run_command - await run_command(self.app, cmd) - - def action_help(self) -> None: - self.app.notify( - "Space + l/j/s/q · / palette · ? help", - timeout=6, - ) - - def action_quit(self) -> None: - self.app.exit() diff --git a/tui/widgets/chrome.py b/tui/widgets/chrome.py new file mode 100644 index 0000000..b769bdc --- /dev/null +++ b/tui/widgets/chrome.py @@ -0,0 +1,114 @@ +"""Shared chrome: TitleBar, ContextHint, CommandBar. + +Each primary screen composes these to match the mockup look. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Input, Static + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +class TitleBar(Static): + """Top band: app name + API URL + commands hint.""" + + DEFAULT_CSS = """ + TitleBar { + height: 1; + background: #1e1b52; + color: #7c79f0; + padding: 0 2; + } + """ + + def on_mount(self) -> None: + try: + app: "AmicoTUI" = self.app # type: ignore[assignment] + api = app.api.base_url + except Exception: + api = "" + screen_name = getattr(self.screen, "title", None) or "AmicoScript" + self.update( + f"AmicoScript — {api} · [b]{screen_name}[/b]" + f" [dim]^p Commands[/dim]" + ) + + +class ContextHint(Static): + """One-line bottom-of-content hint (e.g. row counts + keybinds).""" + + DEFAULT_CSS = """ + ContextHint { + height: 1; + background: #0c0e1a; + color: #6b6e9a; + padding: 0 2; + border-top: solid #2a2860; + } + """ + + text: reactive[str] = reactive("") + + def __init__(self, text: str = "", **kwargs) -> None: + super().__init__(text, **kwargs) + self.text = text + + def set_text(self, text: str) -> None: + self.text = text + self.update(text) + + +class CommandBar(Widget): + """Persistent command bar at the bottom of primary screens. + + Typing a slash command and pressing Enter runs it. Pressing ``/`` from + elsewhere still opens the modal Palette via app binding. + """ + + DEFAULT_CSS = """ + CommandBar { + height: 3; + background: #1a1d35; + border-top: solid #4a47c0; + } + CommandBar Horizontal { + height: 3; + } + CommandBar #prompt { + width: 3; + height: 3; + content-align: center middle; + color: #7c79f0; + background: #1a1d35; + } + CommandBar Input { + height: 3; + border: none; + background: #1a1d35; + color: #dde1ff; + } + CommandBar Input:focus { + border: none; + } + """ + + PLACEHOLDER = "type / for commands, /library, /jobs, /settings…" + + def compose(self): + with Horizontal(): + yield Static("❯", id="prompt") + yield Input(placeholder=self.PLACEHOLDER, id="cmdinput") + + async def on_input_submitted(self, event: Input.Submitted) -> None: + from ..commands import run_command + text = (event.value or "").strip() + event.input.value = "" + if not text: + return + await run_command(self.app, text) diff --git a/tui/widgets/segment_list.py b/tui/widgets/segment_list.py index a858988..f816582 100644 --- a/tui/widgets/segment_list.py +++ b/tui/widgets/segment_list.py @@ -6,6 +6,9 @@ from textual.widgets.option_list import Option +SPEAKER_COLORS = ["#22c55e", "#2dd4bf", "#f59e0b", "#7c79f0", "#ef4444", "#dde1ff"] + + def _fmt_ts(seconds: float) -> str: s = int(seconds) h, rem = divmod(s, 3600) @@ -26,16 +29,27 @@ class SegmentList(OptionList): ] DEFAULT_CSS = """ - SegmentList { height: 1fr; border: tall $panel; } + SegmentList { + height: 1fr; + background: #0c0e1a; + border: none; + } """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.segments: list[dict] = [] + self.speaker_index: dict[str, int] = {} + + def speaker_color(self, name: str) -> str: + if name not in self.speaker_index: + self.speaker_index[name] = len(self.speaker_index) + return SPEAKER_COLORS[self.speaker_index[name] % len(SPEAKER_COLORS)] def load(self, segments: list[dict]) -> None: self.clear_options() self.segments = segments or [] + self.speaker_index.clear() for i, seg in enumerate(self.segments): self.add_option(Option(self._format_row(seg), id=str(i))) @@ -43,10 +57,11 @@ def _format_row(self, seg: dict) -> str: ts = _fmt_ts(float(seg.get("start", 0))) speaker = seg.get("speaker") or seg.get("speaker_label") or "" text = (seg.get("text") or "").strip() - prefix = f"[dim]{ts}[/dim]" + prefix = f"[#6b6e9a]{ts}[/]" if speaker: - prefix += f" [bold]{speaker}[/bold]" - return f"{prefix} {text}" + color = self.speaker_color(speaker) + prefix += f" [{color} b]{speaker}[/]" + return f"{prefix} [#dde1ff]{text}[/]" def selected_segment(self) -> dict | None: idx = self.highlighted diff --git a/tui/widgets/status_bar.py b/tui/widgets/status_bar.py index 934c2a2..ae7ef35 100644 --- a/tui/widgets/status_bar.py +++ b/tui/widgets/status_bar.py @@ -11,33 +11,34 @@ class StatusBar(Widget): DEFAULT_CSS = """ StatusBar { height: 1; - background: $panel; - color: $text-muted; - padding: 0 1; + background: #12152a; + color: #6b6e9a; + padding: 0 2; } - StatusBar.-error { background: $error; color: $text; } - StatusBar.-ok { background: $panel; } - StatusBar.-leader { background: $primary; color: $text; } + StatusBar.-error { background: #ef4444; color: #dde1ff; } + StatusBar.-leader { background: #7c79f0; color: #dde1ff; } """ connection: reactive[str] = reactive("connecting") message: reactive[str] = reactive("") - hint: reactive[str] = reactive("Space leader · / palette · ? help") + hint: reactive[str] = reactive("Space leader · / palette · ? help") leader_hint: reactive[str] = reactive("") def render(self) -> str: if self.leader_hint: return f"LEADER · {self.leader_hint}" - parts = [f"● {self.connection}"] + conn_color = "#22c55e" if self.connection in ("connected", "connecting") else "#ef4444" + left = f"[{conn_color}]●[/] {self.connection}" if self.message: - parts.append(self.message) - parts.append(self.hint) - return " · ".join(parts) + left += f" · {self.message}" + left += f" · {self.hint}" + right = "[dim]? Help[/] [#ef4444]q[/] Quit" + # Padding via spaces between left/right not reliable; use just left and let widget align. + return f"{left} {right}" def set_connection(self, state: str, ok: bool = True) -> None: self.connection = state self.set_class(not ok, "-error") - self.set_class(ok, "-ok") def flash(self, msg: str) -> None: self.message = msg From 360dc9ed9919d669532c5c8ae926483c16e3190a Mon Sep 17 00:00:00 2001 From: Simone Celestino Date: Tue, 19 May 2026 22:28:09 +0200 Subject: [PATCH 03/13] feat: add Whisper model settings and update import functionality --- backend/api/routes/releases.py | 22 ++- backend/api/routes/settings.py | 15 ++- backend/settings.py | 19 +++ tests/test_tui_palette.py | 12 +- tui/api.py | 22 ++- tui/commands.py | 65 ++++++--- tui/palette.py | 237 +++++++++++++++++++++++++++++---- tui/screens/import_.py | 184 +++++++++++++++++++++++-- tui/screens/settings.py | 17 +-- 9 files changed, 519 insertions(+), 74 deletions(-) diff --git a/backend/api/routes/releases.py b/backend/api/routes/releases.py index de9b25a..601d998 100644 --- a/backend/api/routes/releases.py +++ b/backend/api/routes/releases.py @@ -2,7 +2,10 @@ from pathlib import Path -from fastapi import APIRouter, Request +from fastapi import APIRouter, Form, Request +from pydantic import BaseModel + +from settings import _get_whisper_settings, _save_whisper_settings MODELS_META = [ {"id": "tiny", "name": "Tiny", "params": "~39M", "ram": "~1 GB", "speed": 5, "accuracy": 1}, @@ -32,6 +35,23 @@ def get_models() -> list: return MODELS_META +@router.get("/api/whisper/models") +def get_whisper_models() -> dict: + ws = _get_whisper_settings() + return {"models": MODELS_META, "default": ws["whisper_model"]} + + +class WhisperModelPayload(BaseModel): + model: str + + +@router.post("/api/whisper/models") +async def set_whisper_model(payload: WhisperModelPayload) -> dict: + ws = _get_whisper_settings() + _save_whisper_settings(payload.model, ws["whisper_device"], ws["whisper_compute"]) + return {"ok": True, "model": payload.model} + + @router.get("/api/latest-release") def api_latest_release(request: Request) -> dict: info = getattr(request.app.state, "latest_release", {}) or {} diff --git a/backend/api/routes/settings.py b/backend/api/routes/settings.py index fa08fd8..aa78f6c 100644 --- a/backend/api/routes/settings.py +++ b/backend/api/routes/settings.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Form -from settings import _load_settings, _save_settings +from settings import _load_settings, _save_settings, _get_whisper_settings, _save_whisper_settings router = APIRouter() @@ -11,15 +11,26 @@ def get_settings() -> dict: import state settings = _load_settings() + ws = _get_whisper_settings() return { "hf_token": settings.get("hf_token", ""), "exit_token": getattr(state, "exit_token", ""), + "whisper_model": ws["whisper_model"], + "whisper_device": ws["whisper_device"], + "whisper_compute": ws["whisper_compute"], } @router.post("/api/settings") -async def save_settings(hf_token: str = Form("")) -> dict: +async def save_settings( + hf_token: str = Form(""), + whisper_model: str = Form(""), + whisper_device: str = Form(""), + whisper_compute: str = Form(""), +) -> dict: settings = _load_settings() settings["hf_token"] = hf_token _save_settings(settings) + if whisper_model: + _save_whisper_settings(whisper_model, whisper_device or "auto", whisper_compute or "float16") return {"ok": True} diff --git a/backend/settings.py b/backend/settings.py index 7282653..6d2b519 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -70,3 +70,22 @@ def _save_llm_settings(base_url: str, model_name: str, api_key: str) -> None: settings["llm_model_name"] = model_name settings["llm_api_key"] = api_key _save_settings(settings) + + +def _get_whisper_settings() -> dict: + """Return Whisper config: model, device, compute_type.""" + settings = _load_settings() + return { + "whisper_model": settings.get("whisper_model", "small"), + "whisper_device": settings.get("whisper_device", "auto"), + "whisper_compute": settings.get("whisper_compute", "float16"), + } + + +def _save_whisper_settings(model: str, device: str, compute: str) -> None: + """Persist Whisper settings to disk.""" + settings = _load_settings() + settings["whisper_model"] = model + settings["whisper_device"] = device + settings["whisper_compute"] = compute + _save_settings(settings) diff --git a/tests/test_tui_palette.py b/tests/test_tui_palette.py index 7748b70..4f37bab 100644 --- a/tests/test_tui_palette.py +++ b/tests/test_tui_palette.py @@ -40,17 +40,17 @@ def test_entries_from_tags_skips_missing_id(): def test_entries_from_models_mixed_shapes(): out = entries_from_models({"models": [ - {"name": "llama3.1"}, - {"model": "qwen2.5"}, - "mistral", + {"id": "tiny", "name": "Tiny", "params": "~39M", "ram": "~1 GB", "speed": 5, "accuracy": 1}, + {"id": "base", "name": "Base", "params": "~74M", "ram": "~1 GB", "speed": 4, "accuracy": 2}, + "small", ]}) names = {e.key.split(":", 1)[1] for e in out} - assert names == {"llama3.1", "qwen2.5", "mistral"} + assert names == {"tiny", "base", "small"} def test_entries_from_models_handles_bare_list(): - out = entries_from_models(["llama3.1", "qwen2.5"]) - assert {e.display for e in out} == {"⚡ llama3.1", "⚡ qwen2.5"} + out = entries_from_models(["tiny", "base"]) + assert {e.display for e in out} == {"tiny", "base"} def test_entries_from_models_empty(): diff --git a/tui/api.py b/tui/api.py index 1ebd57a..b785c41 100644 --- a/tui/api.py +++ b/tui/api.py @@ -58,6 +58,12 @@ async def version(self) -> dict: async def models(self) -> dict: return await self._get("/api/models") + async def whisper_models(self) -> dict: + return await self._get("/api/whisper/models") + + async def save_whisper_model(self, model: str) -> dict: + return await self._post("/api/whisper/models", json={"model": model}) + async def latest_release(self) -> dict: return await self._get("/api/latest-release") @@ -240,9 +246,21 @@ async def llm_pull_model(self, name: str) -> dict: async def settings(self) -> dict: return await self._get("/api/settings") - async def save_settings(self, hf_token: str | None = None) -> dict: + async def save_settings( + self, + hf_token: str | None = None, + whisper_model: str | None = None, + whisper_device: str | None = None, + whisper_compute: str | None = None, + ) -> dict: return await self._post( - "/api/settings", json=_drop_none({"hf_token": hf_token}) + "/api/settings", + json=_drop_none({ + "hf_token": hf_token, + "whisper_model": whisper_model, + "whisper_device": whisper_device, + "whisper_compute": whisper_compute, + }), ) diff --git a/tui/commands.py b/tui/commands.py index 390d79a..5627c2e 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -58,6 +58,18 @@ async def run_command(app: "AmicoTUI", raw: str) -> None: # --- handlers -------------------------------------------------------- +async def _whisper_options(app) -> dict: + """Return the saved Whisper model from settings for transcribe calls.""" + try: + s = await app.api.settings() + model = s.get("whisper_model", "").strip() + if model: + return {"model": model} + except Exception: + pass + return {} + + @command("help", "show command reference") async def _help(app, args): lines = [f"/{c.name} — {c.help}" for c in list_commands()] @@ -67,19 +79,37 @@ async def _help(app, args): @command("transcribe", "upload and transcribe") async def _transcribe(app, args): if not args: - app.notify("usage: /transcribe ") + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/transcribe ") return - path = Path(args[0]).expanduser() - if not path.is_file(): - app.notify(f"not a file: {path}") + + arg = args[0] + + if arg.startswith("@"): + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, f"/transcribe {arg}") return - result = await app.api.transcribe_file(path) - job_id = result.get("job_id") or result.get("id") - if job_id: - from .screens.job_detail import JobDetailScreen - app.push_screen(JobDetailScreen(job_id)) - else: - app.notify(f"submitted: {result}") + + path = Path(arg).expanduser() + if path.is_file(): + opts = await _whisper_options(app) + result = await app.api.transcribe_file(path, options=opts) + job_id = result.get("job_id") or result.get("id") + if job_id: + from .screens.job_detail import JobDetailScreen + app.push_screen(JobDetailScreen(job_id)) + else: + app.notify(f"submitted: {result}") + return + + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, f"/transcribe {path}") @command("transcribe-url", "transcribe from ") @@ -87,7 +117,8 @@ async def _transcribe_url(app, args): if not args: app.notify("usage: /transcribe-url ") return - result = await app.api.transcribe_url(args[0]) + opts = await _whisper_options(app) + result = await app.api.transcribe_url(args[0], **opts) jobs = result.get("jobs") or ([result] if result.get("job_id") else []) if jobs: from .screens.job_detail import JobDetailScreen @@ -228,7 +259,7 @@ async def _analyze(app, args): app.notify(f"analysis failed: {e}", severity="error") -@command("models", "pick an LLM model") +@command("models", "pick a Whisper transcription model") async def _models(app, args): from .palette import Palette, seed_palette pal = Palette() @@ -236,10 +267,12 @@ async def _models(app, args): pal.call_after_refresh(seed_palette, pal, "/models ") -@command("llm", "open LLM settings") +@command("llm", "pick an LLM model") async def _llm(app, args): - from .screens.settings import SettingsScreen - app.push_screen(SettingsScreen()) + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/llm ") @command("quit", "exit the app") diff --git a/tui/palette.py b/tui/palette.py index f3bcc31..6ac6106 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -18,6 +18,7 @@ from collections import deque from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Callable from textual.binding import Binding @@ -40,7 +41,7 @@ # Commands that, when typed with a trailing space, switch the palette to # a sub-picker. ``new`` arg of /folder is preserved by falling back to # raw command execution on Enter when no folder matches the query. -SUBPICKERS = {"library", "folder", "tag", "analyze", "models"} +SUBPICKERS = {"library", "folder", "tag", "analyze", "models", "llm", "transcribe"} # Map command name → mode key used internally (most are 1:1; /models → "model"). _MODE_BY_COMMAND = { "library": "library", @@ -48,6 +49,8 @@ "tag": "tag", "analyze": "analyze", "models": "model", + "llm": "llm_model", + "transcribe": "transcribe", } @@ -134,6 +137,7 @@ def __init__( self._folders: list[Entry] = [] self._tags: list[Entry] = [] self._models: list[Entry] = [] + self._llm_models: list[Entry] = [] # Visible after filtering, in render order. self._visible: list[Entry] = [] self._mode = "free" @@ -143,6 +147,12 @@ def __init__( self._ad_hoc_entries = entries self._ad_hoc_on_pick = on_pick self._ad_hoc_title = title + # Transcribe-mode file browser state. + self._current_fs_path: Path = Path.home() + self._fs_entries: list[Entry] = [] + self._transcribe_library_mode: bool = False + self._transcribe_filter: str = "" + self._recording_data: dict[str, dict] = {} def compose(self): with Vertical(id="box"): @@ -194,11 +204,13 @@ async def _load_recordings(self) -> None: items = data.get("items", []) if isinstance(data, dict) else (data or []) except Exception: items = [] + self._recording_data = {} out: list[Entry] = [] for r in items: rid = str(r.get("id", "")) name = r.get("alias") or r.get("filename") or f"#{rid}" status = r.get("status", "") + self._recording_data[rid] = r out.append(Entry( kind="recording", key=f"recording:{rid}", @@ -224,11 +236,21 @@ async def _load_models(self) -> None: return app: "AmicoTUI" = self.app # type: ignore[assignment] try: - data = await app.api.llm_models() + data = await app.api.whisper_models() except Exception: data = {} self._models = entries_from_models(data) + async def _load_llm_models(self) -> None: + if self._llm_models: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + data = await app.api.llm_models() + except Exception: + data = {} + self._llm_models = entries_from_llm_models(data) + async def _load_tags(self) -> None: if self._tags: return @@ -239,6 +261,63 @@ async def _load_tags(self) -> None: tags = [] self._tags = entries_from_tags(tags) + # --- filesystem browser helpers (transcribe mode) -------------------- + + def _resolve_fs_query(self, query: str) -> tuple[Path, str]: + """Parse transcribe query into (directory_path, filter_string).""" + if not query: + return (getattr(self, '_current_fs_path', Path.home()), "") + if query.startswith("/") or (query.startswith("~") and (len(query) == 1 or query[1] == "/")): + p = Path(query).expanduser() + if p.is_dir(): + return (p, "") + parent = p + while not parent.exists() and parent.parent != parent: + parent = parent.parent + parts = p.parts[len(parent.parts):] + filter_str = "/".join(parts) if parts else "" + return (parent, filter_str) + return (getattr(self, '_current_fs_path', Path.home()), query) + + def _build_fs_entries(self, path: Path) -> list[Entry]: + """List directory contents for the transcribe file browser.""" + from .app import AUDIO_EXTS + entries: list[Entry] = [] + if path.parent != path: + entries.append(Entry( + kind="dir", + key=f"dir:{path.parent}", + display="📁 ..", + subtitle=str(path.parent), + search_text=".. parent", + on_select=_noop, + )) + try: + for p in sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): + if p.name.startswith("."): + continue + if p.is_dir(): + entries.append(Entry( + kind="dir", + key=f"dir:{p}", + display=f"📁 {p.name}/", + subtitle="", + search_text=p.name, + on_select=_noop, + )) + elif p.suffix.lower() in AUDIO_EXTS: + entries.append(Entry( + kind="file", + key=f"file:{p}", + display=f"♪ {p.name}", + subtitle="", + search_text=p.name, + on_select=_noop, + )) + except (PermissionError, OSError): + pass + return entries + # --- mode parsing ---------------------------------------------------- def _parse(self, raw: str) -> tuple[str, str]: @@ -254,6 +333,11 @@ def _parse(self, raw: str) -> tuple[str, str]: return ("free", raw) def _mode_label(self, mode: str) -> str: + if mode == "transcribe": + if self._transcribe_library_mode: + return "library recordings · type to filter · enter re-transcribe · esc close" + path = getattr(self, '_current_fs_path', Path.home()) + return f"browsing: {path} · type to filter · enter dir or transcribe · @ for library · esc close" return { "free": "tab complete · ctrl+p commands · esc close", "command": "tab complete · enter run · esc close", @@ -262,7 +346,8 @@ def _mode_label(self, mode: str) -> str: "tag": "type to filter · enter scopes library · esc close", "transcript": "type to filter · enter opens transcript · esc close", "analyze": "pick a recording · enter chooses analysis type · esc close", - "model": "pick a model · enter sets default · esc close", + "model": "pick a Whisper model · enter sets default · esc close", + "llm_model": "pick an LLM model · enter sets default · esc close", }.get(mode, mode) def _update_hint(self, mode: str) -> None: @@ -277,16 +362,32 @@ async def on_input_changed(self, event) -> None: await self._on_query_change(event.value) async def _on_query_change(self, raw: str) -> None: - mode, _q = self._parse(raw) - if mode != self._mode: + mode, query = self._parse(raw) + mode_changed = mode != self._mode + if mode_changed: self._mode = mode - self._update_hint(mode) if mode == "folder": await self._load_folders() elif mode == "tag": await self._load_tags() elif mode == "model": await self._load_models() + elif mode == "llm_model": + await self._load_llm_models() + + if mode == "transcribe": + q = query.strip() + if q.startswith("@"): + self._transcribe_library_mode = True + self._transcribe_filter = q[1:].lstrip() + else: + self._transcribe_library_mode = False + self._current_fs_path, self._transcribe_filter = self._resolve_fs_query(q) + self._fs_entries = self._build_fs_entries(self._current_fs_path) + + if mode_changed or mode == "transcribe": + self._update_hint(mode) + self._refresh(raw) async def on_input_submitted(self, event) -> None: @@ -310,19 +411,31 @@ def _pool_for_mode(self, mode: str) -> list[Entry]: return self._tags if mode == "model": return self._models + if mode == "llm_model": + return self._llm_models + if mode == "transcribe": + if self._transcribe_library_mode: + return self._recordings + return self._fs_entries # free: commands first, then recordings return self._commands + self._recordings def _refresh(self, raw: str) -> None: mode, query = self._parse(raw) pool = self._pool_for_mode(mode) + + if mode == "transcribe": + effective_query = self._transcribe_filter + else: + effective_query = query + mru = list(getattr(self.app, "_palette_mru", [])) mru_rank = {k: len(mru) - i for i, k in enumerate(mru)} scored: list[tuple[int, Entry]] = [] - if query: + if effective_query: for e in pool: - s = score_match(query, e.search_text) + s = score_match(effective_query, e.search_text) if s is None: continue if e.key in mru_rank: @@ -408,6 +521,9 @@ async def _activate_highlighted(self, fallback_text: str = "") -> None: # No match — if input looks like a raw command, run it. text = fallback_text.strip() if text.startswith("/"): + mode, _ = self._parse(text) + if mode == "transcribe": + return self.app.pop_screen() await run_command(self.app, text) @@ -430,6 +546,35 @@ async def _activate(self, entry_key: str) -> None: self.app.pop_screen() _open_analysis_type_picker(self.app, rec_id) return + # Transcribe mode: file browser or library recording re-transcribe. + if self._mode == "transcribe": + if entry.kind == "dir": + new_path = Path(entry.key.split(":", 1)[1]) + self._current_fs_path = new_path + self._transcribe_filter = "" + self._fs_entries = self._build_fs_entries(new_path) + inp = self.query_one(CommandInput) + inp.value = f"/transcribe {new_path}/" + inp.cursor_position = len(inp.value) + self._refresh(inp.value) + return + elif entry.kind == "file": + from .app import shquote + file_path = entry.key.split(":", 1)[1] + self.app.pop_screen() + await run_command(self.app, f"transcribe {shquote(file_path)}") + return + elif entry.kind == "recording" and self._transcribe_library_mode: + rid = entry.key.split(":", 1)[1] + rec_data = self._recording_data.get(rid, {}) + file_path = rec_data.get("file_path", "") + if file_path: + from .app import shquote + self.app.pop_screen() + await run_command(self.app, f"transcribe {shquote(file_path)}") + else: + self.app.notify(f"source file not available for {rid[:8]}", severity="warning") + return self.app.pop_screen() await entry.on_select(self.app) @@ -442,6 +587,7 @@ def _all_entries(self) -> list[Entry]: + self._folders + self._tags + self._models + + self._llm_models ) @@ -488,11 +634,21 @@ async def go(app: "AmicoTUI") -> None: return go -def _set_default_model(name: str): +def _set_whisper_model(name: str): + async def go(app: "AmicoTUI") -> None: + try: + await app.api.save_whisper_model(name) + app.notify(f"whisper model set to {name}") + except Exception as e: + app.notify(f"failed to save: {e}", severity="error") + return go + + +def _set_llm_model(name: str): async def go(app: "AmicoTUI") -> None: try: await app.api.save_llm_settings(model_name=name) - app.notify(f"default LLM model: {name}") + app.notify(f"LLM model set to {name}") except Exception as e: app.notify(f"failed to save: {e}", severity="error") return go @@ -564,25 +720,56 @@ def entries_from_tags(tags: list[dict] | None) -> list[Entry]: def entries_from_models(data) -> list[Entry]: items = data.get("models") if isinstance(data, dict) else (data or []) - names: list[str] = [] + entries: list[Entry] = [] + for it in items or []: + if isinstance(it, dict): + mid = str(it.get("id", "")) + if not mid: + continue + name = it.get("name", mid) + params = it.get("params", "") + ram = it.get("ram", "") + subtitle = f"Whisper · {params} · {ram} · accuracy {it.get('accuracy', '?')}/5" + elif isinstance(it, str): + mid = it + name = it + subtitle = "Whisper model" + else: + continue + entries.append(Entry( + kind="model", + key=f"model:{mid}", + display=f"{name}", + subtitle=subtitle, + search_text=f"{mid} {name}", + on_select=_set_whisper_model(mid), + )) + return entries + + +def entries_from_llm_models(data) -> list[Entry]: + items = data if isinstance(data, list) else (data.get("models") if isinstance(data, dict) else []) + entries: list[Entry] = [] for it in items or []: if isinstance(it, str): - names.append(it) + mid = it + name = it elif isinstance(it, dict): - n = it.get("name") or it.get("model") - if n: - names.append(str(n)) - return [ - Entry( - kind="model", - key=f"model:{n}", - display=f"⚡ {n}", + mid = str(it.get("id") or it.get("name") or it.get("model") or "") + name = str(it.get("name") or it.get("id") or mid) + else: + continue + if not mid: + continue + entries.append(Entry( + kind="llm_model", + key=f"llm_model:{mid}", + display=f"{name}", subtitle="set as default LLM model", - search_text=n, - on_select=_set_default_model(n), - ) - for n in names - ] + search_text=mid, + on_select=_set_llm_model(mid), + )) + return entries def seed_palette(pal: "Palette", text: str) -> None: diff --git a/tui/screens/import_.py b/tui/screens/import_.py index 282da0f..ed946f0 100644 --- a/tui/screens/import_.py +++ b/tui/screens/import_.py @@ -1,7 +1,8 @@ """File-browser screen: pick an audio/video file from the local filesystem. Triggered by ``/import [start_path]``. Uses Textual's DirectoryTree but -restricts file selection to known audio/video extensions. +restricts file selection to known audio/video extensions. A ``/`` search +box lets you recursively fuzzy-find files under the current directory. """ from __future__ import annotations @@ -11,7 +12,8 @@ from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import Screen -from textual.widgets import DirectoryTree, Input, Static +from textual.widgets import DirectoryTree, Input, OptionList, Static +from textual.widgets.option_list import Option from ..app import AUDIO_EXTS, shquote from ..widgets.chrome import CommandBar, ContextHint, TitleBar @@ -43,6 +45,8 @@ class ImportScreen(Screen): Binding("q", "pop", "Back"), Binding("h", "go_home", "Home"), Binding("backspace", "go_up", "Up"), + Binding("slash", "focus_search", "Search"), + Binding("ctrl+f", "focus_search", "Search"), ] leader_chords = { @@ -73,11 +77,45 @@ class ImportScreen(Screen): color: #dde1ff; border: none; } + #searchline { + height: 3; + padding: 0 2; + background: #0c0e1a; + color: #dde1ff; + border-bottom: solid #2a2860; + display: none; + } + #searchline Static { + width: 6; + height: 3; + content-align: left middle; + color: #7c79f0; + } + #searchline Input { + height: 3; + background: #0c0e1a; + color: #dde1ff; + border: none; + } DirectoryTree { height: 1fr; background: #0c0e1a; color: #dde1ff; } + #results { + height: 1fr; + background: #0c0e1a; + color: #dde1ff; + border: none; + display: none; + } + #results > .option-list--option-highlighted { + background: #2d2a7a; + color: #dde1ff; + } + #results > .option-list--option-hover { + background: #1a1d35; + } """ def __init__(self, start: Path | None = None) -> None: @@ -86,16 +124,21 @@ def __init__(self, start: Path | None = None) -> None: if not self.start_path.exists(): self.start_path = Path.home() self.title = "Import" + self._search_timer = None def compose(self): yield TitleBar(id="titlebar") with Horizontal(id="pathline"): yield Static("path:", id="pathlabel") yield Input(value=str(self.start_path), id="pathinput") - with Vertical(): + with Horizontal(id="searchline"): + yield Static("find:", id="searchlabel") + yield Input(placeholder="type to search recursively…", id="searchinput") + with Vertical(id="browser"): yield FilteredDirectoryTree(str(self.start_path), id="tree") + yield OptionList(id="results") yield ContextHint( - "↑↓ navigate · ↵ enter dir or pick file · h home · backspace up · Esc cancel", + "↑↓ navigate · ↵ enter dir or pick file · / search · h home · backspace up · Esc cancel", id="ctxhint", ) yield CommandBar(id="cmdbar") @@ -105,13 +148,89 @@ def on_mount(self) -> None: self.query_one(DirectoryTree).focus() async def on_input_submitted(self, event: Input.Submitted) -> None: - if event.input.id != "pathinput": - return - p = Path(event.value).expanduser() - if not p.is_dir(): - self.app.notify(f"not a directory: {p}") + if event.input.id == "pathinput": + p = Path(event.value).expanduser() + if not p.is_dir(): + self.app.notify(f"not a directory: {p}") + return + self._reload(p) + elif event.input.id == "searchinput": + results = self.query_one("#results", OptionList) + if results.display and results.option_count: + opt = results.get_option_at_index(results.highlighted or 0) + if opt and opt.id: + await self._import_path(Path(opt.id)) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "searchinput": + if self._search_timer: + self._search_timer.stop() + self._search_timer = self.set_timer(0.2, self._debounced_search) + + def _debounced_search(self) -> None: + query = self.query_one("#searchinput", Input).value.strip() + self.run_worker(self._do_search(query), exclusive=True, name="search") + + async def _do_search(self, query: str) -> None: + tree = self.query_one("#tree", DirectoryTree) + results = self.query_one("#results", OptionList) + ctx = self.query_one("#ctxhint", ContextHint) + + if not query: + tree.display = True + results.display = False + ctx.set_text( + "↑↓ navigate · ↵ enter dir or pick file · / search · h home · backspace up · Esc cancel" + ) return - self._reload(p) + + tree.display = False + results.display = True + results.clear_options() + + found = [] + qlower = query.lower() + max_results = 200 + max_depth = 5 + root = self.start_path + + try: + stack = [(root, 0)] + while stack: + current, depth = stack.pop() + if depth > max_depth: + continue + try: + for entry in current.iterdir(): + if entry.is_dir(follow_symlinks=False): + stack.append((entry, depth + 1)) + elif entry.is_file(follow_symlinks=False): + if entry.suffix.lower() in AUDIO_EXTS and qlower in entry.name.lower(): + found.append(entry) + if len(found) >= max_results: + stack = [] + break + except PermissionError: + continue + except Exception: + pass + + found.sort(key=lambda p: p.name.lower()) + for p in found: + try: + rel = str(p.relative_to(root)) + except ValueError: + rel = str(p) + results.add_option( + Option(f"♪ {p.name} [#6b6e9a]{rel}[/]", id=str(p)) + ) + + if results.option_count: + results.highlighted = 0 + + ctx.set_text( + f"{len(found)} matches · ↑↓ navigate · ↵ import · / search · Esc clear" + ) def _reload(self, p: Path) -> None: try: @@ -125,6 +244,14 @@ def _reload(self, p: Path) -> None: self.mount(new_tree) self.query_one("#pathinput", Input).value = str(p) self.start_path = p + # clear any active search + self.query_one("#searchinput", Input).value = "" + searchline = self.query_one("#searchline", Horizontal) + searchline.display = False + tree = self.query_one("#tree", DirectoryTree) + results = self.query_one("#results", OptionList) + tree.display = True + results.display = False def action_go_home(self) -> None: self._reload(Path.home()) @@ -133,12 +260,31 @@ def action_go_up(self) -> None: self._reload(self.start_path.parent) def action_pop(self) -> None: + searchline = self.query_one("#searchline", Horizontal) + if searchline.display: + self.action_clear_search() + return self.app.pop_screen() - async def on_directory_tree_file_selected( - self, event: DirectoryTree.FileSelected - ) -> None: - p = Path(event.path) + def action_focus_search(self) -> None: + searchline = self.query_one("#searchline", Horizontal) + searchline.display = True + self.query_one("#searchinput", Input).focus() + + def action_clear_search(self) -> None: + self.query_one("#searchinput", Input).value = "" + searchline = self.query_one("#searchline", Horizontal) + searchline.display = False + tree = self.query_one("#tree", DirectoryTree) + results = self.query_one("#results", OptionList) + tree.display = True + results.display = False + tree.focus() + self.query_one("#ctxhint", ContextHint).set_text( + "↑↓ navigate · ↵ enter dir or pick file · / search · h home · backspace up · Esc cancel" + ) + + async def _import_path(self, p: Path) -> None: if p.suffix.lower() not in AUDIO_EXTS: self.app.notify(f"unsupported: {p.suffix}", severity="warning") return @@ -146,3 +292,13 @@ async def on_directory_tree_file_selected( self.app.notify(f"importing: {p.name}") self.app.pop_screen() await run_command(self.app, f"transcribe {shquote(str(p))}") + + async def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + await self._import_path(Path(event.path)) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if event.option_list.id == "results": + if event.option.id: + self.run_worker(self._import_path(Path(event.option.id)), exclusive=False) diff --git a/tui/screens/settings.py b/tui/screens/settings.py index f8b3bf0..69f0655 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -107,6 +107,9 @@ async def _load(self) -> None: try: s = await app.api.settings() self.query_one("#hf", Input).value = s.get("hf_token") or "" + self.query_one("#model", Input).value = s.get("whisper_model") or "small" + self.query_one("#device", Input).value = s.get("whisper_device") or "auto" + self.query_one("#compute", Input).value = s.get("whisper_compute") or "float16" except Exception as e: self.app.notify(f"settings load failed: {e}", severity="error") try: @@ -116,13 +119,6 @@ async def _load(self) -> None: self.query_one("#llm_key", Input).value = llm.get("api_key") or "" except Exception: pass - try: - mods = await app.api.models() - default = mods.get("default") or mods.get("current") or "" - if default: - self.query_one("#model", Input).value = str(default) - except Exception: - pass async def on_button_pressed(self, event: Button.Pressed) -> None: app: "AmicoTUI" = self.app # type: ignore[assignment] @@ -133,7 +129,12 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id != "save": return try: - await app.api.save_settings(hf_token=self.query_one("#hf", Input).value) + await app.api.save_settings( + hf_token=self.query_one("#hf", Input).value, + whisper_model=self.query_one("#model", Input).value or None, + whisper_device=self.query_one("#device", Input).value or None, + whisper_compute=self.query_one("#compute", Input).value or None, + ) await app.api.save_llm_settings( base_url=self.query_one("#llm_url", Input).value or None, model_name=self.query_one("#llm_model", Input).value or None, From e17d74d4d3ac23f8d711215bf26931a0f504eb06 Mon Sep 17 00:00:00 2001 From: Simone Celestino Date: Wed, 3 Jun 2026 21:33:23 +0200 Subject: [PATCH 04/13] feat: implement welcome screen and update library navigation --- tui/api.py | 40 ++++++++------ tui/app.py | 14 ++--- tui/commands.py | 5 +- tui/palette.py | 69 +++++++++++++++++++++-- tui/screens/library.py | 9 ++- tui/screens/welcome.py | 121 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 tui/screens/welcome.py diff --git a/tui/api.py b/tui/api.py index b785c41..dc0368d 100644 --- a/tui/api.py +++ b/tui/api.py @@ -6,7 +6,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Callable, Iterable +from typing import Any, Callable import httpx @@ -193,21 +193,29 @@ async def transcribe_file( """ path = Path(path) total = path.stat().st_size - sent = 0 - - def reader() -> Iterable[bytes]: - nonlocal sent - with path.open("rb") as f: - while True: - chunk = f.read(64 * 1024) - if not chunk: - break - sent += len(chunk) - if on_progress is not None: - on_progress(sent, total) - yield chunk - - files = {"file": (path.name, reader(), "application/octet-stream")} + + class _ProgressFile: + def __init__(self, filepath, on_progress_cb, total_size): + self._f = open(filepath, "rb") + self._on_progress = on_progress_cb + self._total = total_size + self._sent = 0 + + def read(self, size=-1): + chunk = self._f.read(size) + if self._on_progress is not None: + self._sent += len(chunk) + self._on_progress(self._sent, self._total) + return chunk + + def seek(self, *args): + return self._f.seek(*args) + + def close(self): + self._f.close() + + progress_file = _ProgressFile(path, on_progress, total) + files = {"file": (path.name, progress_file, "application/octet-stream")} data = {k: str(v) for k, v in (options or {}).items() if v is not None} r = await self.client.post("/api/transcribe", data=data, files=files) r.raise_for_status() diff --git a/tui/app.py b/tui/app.py index 94f9f15..3cad3ee 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,8 +1,8 @@ """Main Textual App for AmicoScript TUI. -Modeless, palette-driven. Lands directly on Library. Leader key (Space) -arms per-screen chord maps; ``/`` or ``ctrl+k`` opens the unified fuzzy -palette. +Modeless, palette-driven. Lands on a welcome screen; the library and +other views are pushed on top. Leader key (Space) arms per-screen chord +maps; ``/`` or ``ctrl+k`` opens the unified fuzzy palette. """ from __future__ import annotations @@ -123,8 +123,8 @@ class AmicoTUI(App): BINDINGS = [ Binding("ctrl+c", "quit", "Quit", priority=True, show=False), - Binding("slash", "palette('/')", "Palette", priority=True, show=False), - Binding("at", "palette('@')", "Palette @", priority=True, show=False), + Binding("slash", "palette('/')", "Palette", show=False), + Binding("at", "palette('@')", "Palette @", show=False), Binding("ctrl+k", "palette()", "Palette", priority=True, show=False), Binding("ctrl+p", "palette('/')", "Commands", priority=True, show=False), ] @@ -140,8 +140,8 @@ def __init__(self, cfg: Config, server: ServerManager) -> None: self.leader = LeaderDispatcher(self) def on_mount(self) -> None: - from .screens.library import LibraryScreen - self.push_screen(LibraryScreen()) + from .screens.welcome import WelcomeScreen + self.push_screen(WelcomeScreen()) self.run_worker(self._health_loop(), exclusive=True, name="health") async def on_unmount(self) -> None: diff --git a/tui/commands.py b/tui/commands.py index 5627c2e..799d57d 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -161,7 +161,10 @@ async def _cancel(app, args): @command("delete", "delete recording ") async def _delete(app, args): if not args: - app.notify("usage: /delete ") + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/delete ") return await app.api.delete_recording(args[0]) app.notify(f"deleted {args[0]}") diff --git a/tui/palette.py b/tui/palette.py index 6ac6106..b9e063f 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -41,7 +41,7 @@ # Commands that, when typed with a trailing space, switch the palette to # a sub-picker. ``new`` arg of /folder is preserved by falling back to # raw command execution on Enter when no folder matches the query. -SUBPICKERS = {"library", "folder", "tag", "analyze", "models", "llm", "transcribe"} +SUBPICKERS = {"library", "folder", "tag", "analyze", "models", "llm", "transcribe", "delete"} # Map command name → mode key used internally (most are 1:1; /models → "model"). _MODE_BY_COMMAND = { "library": "library", @@ -51,6 +51,7 @@ "models": "model", "llm": "llm_model", "transcribe": "transcribe", + "delete": "delete", } @@ -346,6 +347,7 @@ def _mode_label(self, mode: str) -> str: "tag": "type to filter · enter scopes library · esc close", "transcript": "type to filter · enter opens transcript · esc close", "analyze": "pick a recording · enter chooses analysis type · esc close", + "delete": "type to filter · enter deletes recording · esc close", "model": "pick a Whisper model · enter sets default · esc close", "llm_model": "pick an LLM model · enter sets default · esc close", }.get(mode, mode) @@ -403,7 +405,7 @@ def _pool_for_mode(self, mode: str) -> list[Entry]: return self._ad_hoc_entries if mode == "command": return self._commands - if mode == "library" or mode == "transcript" or mode == "analyze": + if mode == "library" or mode == "transcript" or mode == "analyze" or mode == "delete": return self._recordings if mode == "folder": return self._folders @@ -483,12 +485,27 @@ def action_prev_suggestion(self) -> None: ) async def action_tab(self) -> None: - """Tab: in command mode, complete to a unique match; otherwise cycle.""" + """Tab: complete to highlighted suggestion, then prefix; otherwise cycle.""" inp = self.query_one(CommandInput) + lst = self.query_one("#suggestions", OptionList) + + # Prefer completing to the currently highlighted suggestion. + if lst.option_count and lst.highlighted is not None: + opt = lst.get_option_at_index(lst.highlighted) + if opt and opt.id: + entry = next((e for e in self._visible if e.key == opt.id), None) + if entry: + completed = _completion_text(entry) + if completed: + inp.value = completed + inp.cursor_position = len(inp.value) + await self._on_query_change(inp.value) + return + + # Fallback: prefix-based completion in command mode. raw = inp.value mode, query = self._parse(raw) if mode == "command": - # Find commands whose name starts with the typed query (case-insens). q = query.lower().split(" ", 1)[0] matches = [c.name for c in list_commands() if c.name.startswith(q)] if len(matches) == 1: @@ -499,14 +516,13 @@ async def action_tab(self) -> None: await self._on_query_change(inp.value) return if len(matches) > 1: - # Complete to longest common prefix. lcp = _longest_common_prefix(matches) if lcp and lcp != q: inp.value = "/" + lcp inp.cursor_position = len(inp.value) await self._on_query_change(inp.value) return - # Fallback: cycle suggestions. + # Final fallback: cycle suggestions. self.action_next_suggestion() # --- activation ------------------------------------------------------ @@ -540,6 +556,21 @@ async def _activate(self, entry_key: str) -> None: self.app.pop_screen() await self._ad_hoc_on_pick(self.app, entry) return + # In delete mode, picking a recording deletes it immediately. + if self._mode == "delete" and entry.kind == "recording": + rec_id = entry.key.split(":", 1)[1] + app: "AmicoTUI" = self.app # type: ignore[assignment] + self.app.pop_screen() + try: + await app.api.delete_recording(rec_id) + app.notify(f"deleted {rec_id[:8]}") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + except Exception as e: + app.notify(f"delete failed: {e}", severity="error") + return + # In analyze mode, picking a recording opens the type chooser. if self._mode == "analyze" and entry.kind == "recording": rec_id = entry.key.split(":", 1)[1] @@ -604,6 +635,32 @@ def _longest_common_prefix(strings: list[str]) -> str: return s1 +def _completion_text(entry: Entry) -> str: + """Generate the slash-command text for a completed entry.""" + if entry.kind == "command": + cmd_name = entry.key.split(":", 1)[1] + suffix = " " if cmd_name in SUBPICKERS else "" + return "/" + cmd_name + suffix + if entry.kind == "recording": + name = entry.display.removeprefix("♪ ").strip() + return "/library " + name + if entry.kind == "folder": + name = entry.display.removeprefix("▣ ").strip() + return "/folder " + name + if entry.kind == "tag": + name = entry.display.removeprefix("# ").strip() + return "/tag " + name + if entry.kind == "model": + return "/models " + entry.display.strip() + if entry.kind == "llm_model": + return "/llm " + entry.display.strip() + if entry.kind in ("file", "dir"): + path = entry.key.split(":", 1)[1] + suffix = "/" if entry.kind == "dir" else "" + return "/transcribe " + path + suffix + return "" + + # --- selection adapters -------------------------------------------------- diff --git a/tui/screens/library.py b/tui/screens/library.py index 88aa200..d7f3ceb 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -224,10 +224,10 @@ def _selected_id(self) -> str | None: class LibraryScreen(Screen): - """Full-screen library view — default landing.""" + """Full-screen library view.""" BINDINGS = [ - Binding("escape", "pop_if_stacked", "Back"), + Binding("escape", "pop", "Back"), ] leader_chords = { @@ -289,8 +289,7 @@ def _on_loaded(self, count: int, total_dur: float) -> None: except Exception: pass - def action_pop_if_stacked(self) -> None: - if len(self.app.screen_stack) > 1: - self.app.pop_screen() + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py new file mode 100644 index 0000000..8712e07 --- /dev/null +++ b/tui/screens/welcome.py @@ -0,0 +1,121 @@ +"""Welcome / home screen — root layer, always visible when closing palette or ESC.""" +from __future__ import annotations + +from textual.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Static + + +LOGO_ART = ( + "[#7c79f0]▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█████[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█████[/] [#7c79f0]█████[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀▀▀▀█[/][#7c79f0]▌[/] [#7c79f0]███▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]███████▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]████▄[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀▀▀▀█[/][#7c79f0]▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█████▌[/] [#7c79f0]█████[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" + "[#7c79f0]▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[/]" +) + + +class WelcomeScreen(Screen): + """Root welcome screen. Never popped — always revealed on palette close / ESC.""" + + leader_chords = { + "l": ("Library", "/library"), + "i": ("Import", "/import"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "m": ("Models", "/models"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + WelcomeScreen { + layout: vertical; + } + WelcomeScreen Container { + height: 1fr; + align: center middle; + } + #welcome-panel { + width: auto; + height: auto; + align: center middle; + } + #left-col { + width: 1fr; + height: auto; + padding: 0 4; + } + #logo-ascii { + width: 48; + height: auto; + content-align: center middle; + } + #app-title { + color: #7c79f0; + text-style: bold; + content-align: center middle; + height: auto; + width: auto; + } + #tagline { + color: #6b6e9a; + content-align: center middle; + height: auto; + width: auto; + padding: 1 0; + } + #keyref { + color: #3a3d6a; + content-align: center middle; + height: auto; + width: auto; + padding: 1 0; + } + #quick-actions { + color: #dde1ff; + content-align: center middle; + height: auto; + width: auto; + padding: 1 0; + } + #quick-actions Static { + width: auto; + color: #6b6e9a; + } + """ + + def compose(self): + with Container(): + with Horizontal(id="welcome-panel"): + with Vertical(id="left-col"): + yield Static("AmicoScript TUI", id="app-title") + yield Static( + "local-first audio & video transcription", + id="tagline", + ) + yield Static( + "[bold #7c79f0]/[/] [bold #dde1ff]library[/] browse recordings\n" + "[bold #7c79f0]/[/] [bold #dde1ff]transcribe[/] upload & transcribe a file\n" + "[bold #7c79f0]/[/] [bold #dde1ff]import[/] browse filesystem\n" + "[bold #7c79f0]/[/] [bold #dde1ff]jobs[/] active & completed jobs\n" + "[bold #7c79f0]/[/] [bold #dde1ff]settings[/] configure models & tokens\n" + "[bold #7c79f0]/[/] [bold #dde1ff]search[/] full-text search transcripts\n" + "[bold #7c79f0]/[/] [bold #dde1ff]models[/] pick Whisper model\n" + "[bold #7c79f0]/[/] [bold #dde1ff]llm[/] pick LLM model", + id="quick-actions", + ) + yield Static( + "[dim]ctrl+k / ctrl+p[/] command palette · " + "[dim]space[/] leader chords · " + "[dim]?[/] help · " + "[dim]ctrl+c[/] quit", + id="keyref", + ) + yield Static(LOGO_ART, id="logo-ascii") + from ..widgets.status_bar import StatusBar + yield StatusBar(id="statusbar") + From 1642d299af245dd3fefb006966d00e82227f8d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 13:39:56 +0000 Subject: [PATCH 05/13] tui: close the UX gaps between the docs and the actual keyboard experience - Fix Space-q on 5 screens: was labeled "Back" but ran /quit, silently exiting the whole app instead of just quitting as intended. Relabeled to "Quit" everywhere for consistency, and added the README-promised Space-h ("Welcome") and Space-? ("Help") chords app-wide so both are finally real instead of just documented. - Add bare l/j/s shortcuts on the welcome screen, matching the README. - Add a reusable ConfirmDialog modal and wire it into every delete path (/delete, the palette's delete mode, and Library's `d` key) instead of deleting immediately or just telling the user to retype a command. - Add a persistent "N jobs running" indicator to the status bar, polled globally from the App so it survives navigating away from /jobs. - Replace the 10s toast /help with a proper scrollable HelpScreen listing every command and the leader-chord cheatsheet. - Bring JobDetailScreen in line with every other screen's chrome (TitleBar/ContextHint/CommandBar/leader_chords) instead of a bare Textual Header with no leader support. - Fix README: /models and /llm were documented backwards (Whisper vs LLM model pickers were swapped), and it claimed no audio playback despite playback.py being fully wired into the transcript screen. - Remove dead per-line color computation in logs.py (Log widget has highlight=False and can't render it). Verified via a headless Textual Pilot smoke test exercising leader navigation, the confirm-dialog flow, and JobDetailScreen mounting. --- tui/README.md | 8 ++--- tui/app.py | 19 ++++++++++ tui/commands.py | 22 +++++++++--- tui/palette.py | 8 ++++- tui/screens/help.py | 71 ++++++++++++++++++++++++++++++++++++ tui/screens/import_.py | 5 ++- tui/screens/job_detail.py | 19 ++++++++-- tui/screens/jobs_list.py | 2 ++ tui/screens/library.py | 19 +++++++++- tui/screens/logs.py | 24 +++---------- tui/screens/search.py | 4 ++- tui/screens/settings.py | 4 ++- tui/screens/transcript.py | 4 ++- tui/screens/welcome.py | 16 ++++++++- tui/widgets/confirm.py | 75 +++++++++++++++++++++++++++++++++++++++ tui/widgets/status_bar.py | 8 +++-- 16 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 tui/screens/help.py create mode 100644 tui/widgets/confirm.py diff --git a/tui/README.md b/tui/README.md index d4ecb4e..7be2197 100644 --- a/tui/README.md +++ b/tui/README.md @@ -68,7 +68,8 @@ switches mode and filters a different source: | `/folder ` | folder | folders | open library scoped to folder | | `/tag ` | tag | tags | open library scoped to tag | | `/analyze ` | analyze | recordings | choose analysis type, then queue | -| `/models ` | model | LLM models | set as default model | +| `/models ` | model | Whisper models | set as default transcription model | +| `/llm ` | llm_model | LLM models | set as default LLM model | | `@` | transcript | recordings | open transcript | ### Library @@ -126,8 +127,8 @@ Press `/` to open the command palette. | `/folder` | Pick a folder (or `/folder new ` to create) | | `/tag` | Pick a tag | | `/analyze` | Pick a recording and run summary / action_items / translate / custom | -| `/models` | Pick an LLM model (sets as default) | -| `/llm` | Open LLM settings | +| `/models` | Pick a Whisper transcription model (sets as default) | +| `/llm` | Pick an LLM model (sets as default) | | `/jobs` | Open the active-jobs list | | `/welcome` | Return to the welcome screen | | `/settings` | Open settings screen | @@ -159,7 +160,6 @@ soundfile, and discarded on screen exit. ## Limitations (v1) - No segment editing (view + copy only) -- No audio playback - No multi-select copy across segments (single segment via `y`, full transcript via `Y`) - Folder/tag pickers not yet wired into library filtering UI diff --git a/tui/app.py b/tui/app.py index 3cad3ee..b45740a 100644 --- a/tui/app.py +++ b/tui/app.py @@ -143,6 +143,7 @@ def on_mount(self) -> None: from .screens.welcome import WelcomeScreen self.push_screen(WelcomeScreen()) self.run_worker(self._health_loop(), exclusive=True, name="health") + self.run_worker(self._jobs_loop(), exclusive=True, name="jobs_poll") async def on_unmount(self) -> None: await self.api.aclose() @@ -167,6 +168,24 @@ async def _health_loop(self) -> None: await asyncio.sleep(backoff) backoff = min(30.0, backoff * 2) + async def _jobs_loop(self) -> None: + """Poll active-job count so every screen's StatusBar can show it — + without this, background transcriptions vanish from view once you + leave the Jobs screen.""" + import asyncio + + from .widgets.status_bar import StatusBar + + while True: + try: + data = await self.api.jobs() + rows = data.get("jobs", []) if isinstance(data, dict) else [] + for bar in self.query(StatusBar): + bar.active_jobs = len(rows) + except Exception: + pass + await asyncio.sleep(3.0) + # --- key intercept (leader) ----------------------------------------- def on_key(self, event: Key) -> None: diff --git a/tui/commands.py b/tui/commands.py index 799d57d..ee3bf52 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -72,8 +72,15 @@ async def _whisper_options(app) -> dict: @command("help", "show command reference") async def _help(app, args): - lines = [f"/{c.name} — {c.help}" for c in list_commands()] - app.notify("\n".join(lines), timeout=10) + from .screens.help import HelpScreen + app.push_screen(HelpScreen()) + + +@command("welcome", "return to the welcome screen") +async def _welcome(app, args): + from .screens.welcome import WelcomeScreen + while not isinstance(app.screen, WelcomeScreen) and len(app.screen_stack) > 1: + app.pop_screen() @command("transcribe", "upload and transcribe") @@ -166,8 +173,15 @@ async def _delete(app, args): app.push_screen(pal) pal.call_after_refresh(seed_palette, pal, "/delete ") return - await app.api.delete_recording(args[0]) - app.notify(f"deleted {args[0]}") + rec_id = args[0] + from .widgets.confirm import ConfirmDialog + confirmed = await app.push_screen_wait( + ConfirmDialog(f"Delete recording {rec_id[:8]}…? This cannot be undone.") + ) + if not confirmed: + return + await app.api.delete_recording(rec_id) + app.notify(f"deleted {rec_id}") screen = app.screen if hasattr(screen, "refresh_library"): screen.refresh_library() diff --git a/tui/palette.py b/tui/palette.py index b9e063f..2315362 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -556,11 +556,17 @@ async def _activate(self, entry_key: str) -> None: self.app.pop_screen() await self._ad_hoc_on_pick(self.app, entry) return - # In delete mode, picking a recording deletes it immediately. + # In delete mode, picking a recording confirms, then deletes. if self._mode == "delete" and entry.kind == "recording": rec_id = entry.key.split(":", 1)[1] app: "AmicoTUI" = self.app # type: ignore[assignment] self.app.pop_screen() + from .widgets.confirm import ConfirmDialog + confirmed = await app.push_screen_wait( + ConfirmDialog(f"Delete recording {rec_id[:8]}…? This cannot be undone.") + ) + if not confirmed: + return try: await app.api.delete_recording(rec_id) app.notify(f"deleted {rec_id[:8]}") diff --git a/tui/screens/help.py b/tui/screens/help.py new file mode 100644 index 0000000..c45217e --- /dev/null +++ b/tui/screens/help.py @@ -0,0 +1,71 @@ +"""Scrollable command / keybinding reference (Space ? or /help).""" +from __future__ import annotations + +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.screen import Screen +from textual.widgets import Static + +from ..commands import list_commands +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +LEADER_CHEATSHEET = ( + "[b #6b6e9a]LEADER CHORDS (Space + …)[/]\n" + " l Library j Jobs s Settings\n" + " i Import h Welcome ? This screen\n" + " q Quit\n" + "\n" + "[b #6b6e9a]PALETTE[/]\n" + " / open palette (commands)\n" + " @ open palette (transcripts)\n" + " Ctrl+K open palette (free fuzzy)\n" + " Ctrl+P open palette (commands)\n" + " Tab autocomplete / cycle\n" + " ↑↓ / Shift+Tab move selection\n" + " Enter activate\n" + " Escape close\n" +) + + +class HelpScreen(Screen): + BINDINGS = [ + Binding("escape", "pop", "Back"), + Binding("q", "pop", "Back"), + ] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "h": ("Welcome", "/welcome"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + HelpScreen { layout: vertical; } + VerticalScroll { height: 1fr; padding: 1 2; } + #commands { padding-top: 1; } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "Help" + + def compose(self): + yield TitleBar(id="titlebar") + with Vertical(): + with VerticalScroll(): + yield Static(LEADER_CHEATSHEET, id="cheatsheet") + lines = "\n".join( + f" /{c.name:<16} {c.help}" for c in list_commands() + ) + yield Static( + f"[b #6b6e9a]SLASH COMMANDS[/]\n{lines}", id="commands" + ) + yield ContextHint("Esc / q back · Space h welcome", id="ctxhint") + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") + + def action_pop(self) -> None: + self.app.pop_screen() diff --git a/tui/screens/import_.py b/tui/screens/import_.py index ed946f0..955da50 100644 --- a/tui/screens/import_.py +++ b/tui/screens/import_.py @@ -49,11 +49,14 @@ class ImportScreen(Screen): Binding("ctrl+f", "focus_search", "Search"), ] + # No Space-h chord here — bare "h" already means "filesystem home" on + # this screen; a second "Welcome" meaning behind the leader would clash. leader_chords = { "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), - "q": ("Back", "/quit"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), } DEFAULT_CSS = """ diff --git a/tui/screens/job_detail.py b/tui/screens/job_detail.py index 43c8386..d1aeb87 100644 --- a/tui/screens/job_detail.py +++ b/tui/screens/job_detail.py @@ -6,9 +6,10 @@ from textual.binding import Binding from textual.containers import Vertical from textual.screen import Screen -from textual.widgets import Header, Log, Static +from textual.widgets import Log, Static from ..sse import stream_job +from ..widgets.chrome import CommandBar, ContextHint, TitleBar from ..widgets.progress_bar import JobProgress from ..widgets.status_bar import StatusBar @@ -23,6 +24,15 @@ class JobDetailScreen(Screen): Binding("c", "cancel", "Cancel job"), ] + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), + } + DEFAULT_CSS = """ JobDetailScreen { layout: vertical; } #title { padding: 0 1; height: 1; } @@ -32,14 +42,17 @@ class JobDetailScreen(Screen): def __init__(self, job_id: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.job_id = job_id + self.title = f"Job · {job_id[:8]}" def compose(self): - yield Header() + yield TitleBar(id="titlebar") with Vertical(): yield Static(f"job [b]{self.job_id}[/b]", id="title") yield JobProgress(id="prog") yield Log(id="log", highlight=False, max_lines=2000) - yield StatusBar(id="statusbar") + yield ContextHint("c cancel job · Esc / q back", id="ctxhint") + yield CommandBar(id="cmdbar") + yield StatusBar(id="statusbar") def on_mount(self) -> None: self.run_worker(self._stream(), exclusive=True) diff --git a/tui/screens/jobs_list.py b/tui/screens/jobs_list.py index bdfe20f..de623b9 100644 --- a/tui/screens/jobs_list.py +++ b/tui/screens/jobs_list.py @@ -130,6 +130,8 @@ class JobsListScreen(Screen): "l": ("Library", "/library"), "s": ("Settings", "/settings"), "i": ("Import", "/import"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), "q": ("Quit", "/quit"), } diff --git a/tui/screens/library.py b/tui/screens/library.py index d7f3ceb..0e43f59 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -150,7 +150,22 @@ def action_delete_row(self) -> None: rec_id = self._selected_id() if rec_id is None: return - self.app.notify(f"/delete {rec_id} to confirm deletion") + self.run_worker(self._delete_selected(rec_id), exclusive=False) + + async def _delete_selected(self, rec_id: str) -> None: + from ..widgets.confirm import ConfirmDialog + confirmed = await self.app.push_screen_wait( + ConfirmDialog(f"Delete recording {rec_id[:8]}…? This cannot be undone.") + ) + if not confirmed: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + await app.api.delete_recording(rec_id) + app.notify(f"deleted {rec_id[:8]}") + self.refresh_library() + except Exception as e: + app.notify(f"delete failed: {e}", severity="error") def action_copy_name(self) -> None: rec_id = self._selected_id() @@ -234,6 +249,8 @@ class LibraryScreen(Screen): "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), "i": ("Import", "/import"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), "q": ("Quit", "/quit"), } diff --git a/tui/screens/logs.py b/tui/screens/logs.py index a002a04..e2b0602 100644 --- a/tui/screens/logs.py +++ b/tui/screens/logs.py @@ -1,7 +1,6 @@ """Live server log tail screen.""" from __future__ import annotations -import re from typing import TYPE_CHECKING from textual.binding import Binding @@ -16,9 +15,6 @@ from ..app import AmicoTUI -LEVEL_RE = re.compile(r"\b(INFO|WARN(?:ING)?|ERROR|DEBUG|CRITICAL)\b") - - class LogsScreen(Screen): BINDINGS = [ Binding("escape", "pop", "Back"), @@ -30,7 +26,9 @@ class LogsScreen(Screen): "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), - "q": ("Back", "/quit"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), } DEFAULT_CSS = """ @@ -88,20 +86,8 @@ def _poll(self) -> None: self._last_n = len(lines) def _style(self, line: str) -> str: - m = LEVEL_RE.search(line) - if not m: - return line - level = m.group(1) - color = { - "INFO": "#2dd4bf", - "WARN": "#f59e0b", - "WARNING": "#f59e0b", - "ERROR": "#ef4444", - "CRITICAL": "#ef4444", - "DEBUG": "#6b6e9a", - }.get(level, "#6b6e9a") - # Log widget supports limited markup via highlight=False — strip styling. - # Return plain text; coloring via inline markup not supported in Log widget cleanly. + # Log widget is highlight=False (no markup rendering), so levels + # can't be colorized here — passed through as-is. return line def action_clear(self) -> None: diff --git a/tui/screens/search.py b/tui/screens/search.py index a8cdad9..a4e5ccc 100644 --- a/tui/screens/search.py +++ b/tui/screens/search.py @@ -41,7 +41,9 @@ class SearchScreen(Screen): "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), - "q": ("Back", "/quit"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), } DEFAULT_CSS = """ diff --git a/tui/screens/settings.py b/tui/screens/settings.py index 69f0655..05ccece 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -152,7 +152,9 @@ class SettingsScreen(Screen): "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), "i": ("Import", "/import"), - "q": ("Back", "/quit"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), } DEFAULT_CSS = """ diff --git a/tui/screens/transcript.py b/tui/screens/transcript.py index 7b03498..45c7242 100644 --- a/tui/screens/transcript.py +++ b/tui/screens/transcript.py @@ -55,7 +55,9 @@ class TranscriptScreen(Screen): "l": ("Library", "/library"), "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), - "q": ("Back", "/quit"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), } def __init__(self, recording_id: str, *args, **kwargs) -> None: diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py index 8712e07..8f33043 100644 --- a/tui/screens/welcome.py +++ b/tui/screens/welcome.py @@ -1,6 +1,7 @@ """Welcome / home screen — root layer, always visible when closing palette or ESC.""" from __future__ import annotations +from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical from textual.screen import Screen from textual.widgets import Static @@ -28,9 +29,18 @@ class WelcomeScreen(Screen): "j": ("Jobs", "/jobs"), "s": ("Settings", "/settings"), "m": ("Models", "/models"), + "question_mark": ("Help", "/help"), "q": ("Quit", "/quit"), } + # Bare l/j/s jump directly on the welcome screen (README "Keys" section); + # every other screen requires the Space leader first. + BINDINGS = [ + Binding("l", "goto('/library')", show=False), + Binding("j", "goto('/jobs')", show=False), + Binding("s", "goto('/settings')", show=False), + ] + DEFAULT_CSS = """ WelcomeScreen { layout: vertical; @@ -111,7 +121,7 @@ def compose(self): yield Static( "[dim]ctrl+k / ctrl+p[/] command palette · " "[dim]space[/] leader chords · " - "[dim]?[/] help · " + "[dim]space ?[/] help · " "[dim]ctrl+c[/] quit", id="keyref", ) @@ -119,3 +129,7 @@ def compose(self): from ..widgets.status_bar import StatusBar yield StatusBar(id="statusbar") + def action_goto(self, cmd: str) -> None: + from ..commands import run_command + self.run_worker(run_command(self.app, cmd), exclusive=False) + diff --git a/tui/widgets/confirm.py b/tui/widgets/confirm.py new file mode 100644 index 0000000..10b6381 --- /dev/null +++ b/tui/widgets/confirm.py @@ -0,0 +1,75 @@ +"""Reusable yes/no confirmation modal for destructive actions. + +Usage: ``confirmed = await app.push_screen_wait(ConfirmDialog("Delete X?"))`` +""" +from __future__ import annotations + +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class ConfirmDialog(ModalScreen[bool]): + """Modal that resolves to ``True`` (confirm) or ``False`` (cancel).""" + + DEFAULT_CSS = """ + ConfirmDialog { + align: center middle; + background: rgba(12,14,26,0.85); + } + #box { + width: 60; + height: auto; + padding: 1 2; + background: #12152a; + border: tall #ef4444; + } + #message { + color: #dde1ff; + padding: 0 0 1 0; + } + #buttons { + height: 3; + align: right middle; + } + #buttons Button { + margin-left: 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("n", "cancel", show=False), + Binding("y", "confirm", show=False), + ] + + def __init__( + self, + message: str, + confirm_label: str = "Delete", + cancel_label: str = "Cancel", + ) -> None: + super().__init__() + self.message = message + self.confirm_label = confirm_label + self.cancel_label = cancel_label + + def compose(self): + with Vertical(id="box"): + yield Static(self.message, id="message") + with Horizontal(id="buttons"): + yield Button(self.cancel_label, id="cancel") + yield Button(self.confirm_label, id="confirm", variant="error") + + def on_mount(self) -> None: + self.query_one("#cancel", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "confirm") + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/tui/widgets/status_bar.py b/tui/widgets/status_bar.py index ae7ef35..a603ab0 100644 --- a/tui/widgets/status_bar.py +++ b/tui/widgets/status_bar.py @@ -21,18 +21,22 @@ class StatusBar(Widget): connection: reactive[str] = reactive("connecting") message: reactive[str] = reactive("") - hint: reactive[str] = reactive("Space leader · / palette · ? help") + hint: reactive[str] = reactive("Space leader · / palette") leader_hint: reactive[str] = reactive("") + active_jobs: reactive[int] = reactive(0) def render(self) -> str: if self.leader_hint: return f"LEADER · {self.leader_hint}" conn_color = "#22c55e" if self.connection in ("connected", "connecting") else "#ef4444" left = f"[{conn_color}]●[/] {self.connection}" + if self.active_jobs: + noun = "job" if self.active_jobs == 1 else "jobs" + left += f" · [#f59e0b]⚙ {self.active_jobs} {noun} running[/]" if self.message: left += f" · {self.message}" left += f" · {self.hint}" - right = "[dim]? Help[/] [#ef4444]q[/] Quit" + right = "[dim]Space ?[/] Help [#ef4444]Space q[/] Quit" # Padding via spaces between left/right not reliable; use just left and let widget align. return f"{left} {right}" From 55a5d173eb0d1b9353cee18663bbea44dddc329e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 13:59:15 +0000 Subject: [PATCH 06/13] tui: fix welcome screen layout and stop clipping right-aligned bar text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome screen's hand-drawn ASCII logo was misaligned and rendered as an illegible block of characters, clipped at the right edge of its fixed-width container. Replaced it with a simple bordered, centered card (app name + tagline + command list) using Textual's own border/align primitives instead of hand-computed block characters, so it can't drift out of alignment again. TitleBar and StatusBar both right-aligned their trailing hint text by concatenating a fixed run of literal spaces before it — reliable only at one specific terminal width, clipping the hint everywhere narrower. Rebuilt both as two real widgets (1fr left / auto right) so the layout engine handles the alignment instead of a guessed space count. Verified by rendering the actual app (headless Textual Pilot -> export_screenshot -> Chromium) rather than judging from source alone. --- tui/screens/welcome.py | 106 +++++++++++++------------------------- tui/widgets/chrome.py | 25 +++++++-- tui/widgets/status_bar.py | 66 +++++++++++++++++++----- 3 files changed, 112 insertions(+), 85 deletions(-) diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py index 8f33043..6d4484e 100644 --- a/tui/screens/welcome.py +++ b/tui/screens/welcome.py @@ -2,24 +2,11 @@ from __future__ import annotations from textual.binding import Binding -from textual.containers import Container, Horizontal, Vertical +from textual.containers import Container, Vertical from textual.screen import Screen from textual.widgets import Static -LOGO_ART = ( - "[#7c79f0]▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█████[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█████[/] [#7c79f0]█████[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀▀▀▀█[/][#7c79f0]▌[/] [#7c79f0]███▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]███████▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]████▄[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀▀▀▀█[/][#7c79f0]▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▀▀[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]█[/][#dde1ff]▌[/] [#7c79f0]█▌[/] [#7c79f0]█ █▌[/] [#7c79f0]█████▌[/] [#7c79f0]█████[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]██[/][#dde1ff]▌[/] [#7c79f0]▐██[/]\n" - "[#7c79f0]▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[/]" -) - - class WelcomeScreen(Screen): """Root welcome screen. Never popped — always revealed on palette close / ESC.""" @@ -45,91 +32,72 @@ class WelcomeScreen(Screen): WelcomeScreen { layout: vertical; } - WelcomeScreen Container { + WelcomeScreen > Container { height: 1fr; align: center middle; } #welcome-panel { width: auto; height: auto; + border: round #4a47c0; + padding: 1 5; align: center middle; } - #left-col { - width: 1fr; - height: auto; - padding: 0 4; - } - #logo-ascii { - width: 48; - height: auto; - content-align: center middle; - } #app-title { color: #7c79f0; text-style: bold; - content-align: center middle; + width: 100%; + text-align: center; height: auto; - width: auto; } #tagline { color: #6b6e9a; - content-align: center middle; + width: 100%; + text-align: center; height: auto; - width: auto; - padding: 1 0; - } - #keyref { - color: #3a3d6a; - content-align: center middle; - height: auto; - width: auto; - padding: 1 0; + padding: 0 0 1 0; } #quick-actions { color: #dde1ff; - content-align: center middle; - height: auto; width: auto; + height: auto; padding: 1 0; } - #quick-actions Static { - width: auto; - color: #6b6e9a; + #keyref { + color: #3a3d6a; + width: 100%; + text-align: center; + height: auto; + padding: 1 0 0 0; } """ def compose(self): with Container(): - with Horizontal(id="welcome-panel"): - with Vertical(id="left-col"): - yield Static("AmicoScript TUI", id="app-title") - yield Static( - "local-first audio & video transcription", - id="tagline", - ) - yield Static( - "[bold #7c79f0]/[/] [bold #dde1ff]library[/] browse recordings\n" - "[bold #7c79f0]/[/] [bold #dde1ff]transcribe[/] upload & transcribe a file\n" - "[bold #7c79f0]/[/] [bold #dde1ff]import[/] browse filesystem\n" - "[bold #7c79f0]/[/] [bold #dde1ff]jobs[/] active & completed jobs\n" - "[bold #7c79f0]/[/] [bold #dde1ff]settings[/] configure models & tokens\n" - "[bold #7c79f0]/[/] [bold #dde1ff]search[/] full-text search transcripts\n" - "[bold #7c79f0]/[/] [bold #dde1ff]models[/] pick Whisper model\n" - "[bold #7c79f0]/[/] [bold #dde1ff]llm[/] pick LLM model", - id="quick-actions", - ) - yield Static( - "[dim]ctrl+k / ctrl+p[/] command palette · " - "[dim]space[/] leader chords · " - "[dim]space ?[/] help · " - "[dim]ctrl+c[/] quit", - id="keyref", - ) - yield Static(LOGO_ART, id="logo-ascii") + with Vertical(id="welcome-panel"): + yield Static("AmicoScript", id="app-title") + yield Static( + "local-first audio & video transcription", id="tagline" + ) + yield Static( + "[bold #7c79f0]/[/] [bold #dde1ff]library[/] browse recordings\n" + "[bold #7c79f0]/[/] [bold #dde1ff]transcribe[/] upload & transcribe a file\n" + "[bold #7c79f0]/[/] [bold #dde1ff]import[/] browse filesystem\n" + "[bold #7c79f0]/[/] [bold #dde1ff]jobs[/] active & completed jobs\n" + "[bold #7c79f0]/[/] [bold #dde1ff]settings[/] configure models & tokens\n" + "[bold #7c79f0]/[/] [bold #dde1ff]search[/] full-text search transcripts\n" + "[bold #7c79f0]/[/] [bold #dde1ff]models[/] pick Whisper model\n" + "[bold #7c79f0]/[/] [bold #dde1ff]llm[/] pick LLM model", + id="quick-actions", + ) + yield Static( + "[dim]ctrl+k[/] palette · [dim]space[/] leader\n" + "[dim]space ?[/] help · [dim]ctrl+c[/] quit", + id="keyref", + ) from ..widgets.status_bar import StatusBar yield StatusBar(id="statusbar") def action_goto(self, cmd: str) -> None: from ..commands import run_command self.run_worker(run_command(self.app, cmd), exclusive=False) - diff --git a/tui/widgets/chrome.py b/tui/widgets/chrome.py index b769bdc..0ffe370 100644 --- a/tui/widgets/chrome.py +++ b/tui/widgets/chrome.py @@ -15,18 +15,36 @@ from ..app import AmicoTUI -class TitleBar(Static): - """Top band: app name + API URL + commands hint.""" +class TitleBar(Widget): + """Top band: app name + API URL on the left, commands hint on the right. + + Two real widgets (``1fr`` / ``auto``) instead of a single string padded + with a fixed run of spaces — that broke (clipped the right side) on + narrower terminals. + """ DEFAULT_CSS = """ TitleBar { height: 1; background: #1e1b52; + layout: horizontal; + } + TitleBar #title-left { + width: 1fr; + color: #7c79f0; + padding: 0 2; + } + TitleBar #title-right { + width: auto; color: #7c79f0; padding: 0 2; } """ + def compose(self): + yield Static(id="title-left") + yield Static("[dim]^p Commands[/dim]", id="title-right") + def on_mount(self) -> None: try: app: "AmicoTUI" = self.app # type: ignore[assignment] @@ -34,9 +52,8 @@ def on_mount(self) -> None: except Exception: api = "" screen_name = getattr(self.screen, "title", None) or "AmicoScript" - self.update( + self.query_one("#title-left", Static).update( f"AmicoScript — {api} · [b]{screen_name}[/b]" - f" [dim]^p Commands[/dim]" ) diff --git a/tui/widgets/status_bar.py b/tui/widgets/status_bar.py index a603ab0..e16d5df 100644 --- a/tui/widgets/status_bar.py +++ b/tui/widgets/status_bar.py @@ -3,20 +3,37 @@ from textual.reactive import reactive from textual.widget import Widget +from textual.widgets import Static class StatusBar(Widget): - """Single-line status bar at the bottom of the app.""" + """Single-line status bar at the bottom of the app. + + Left/right halves are real widgets laid out with ``1fr``/``auto`` + widths, so the right-aligned hint never gets clipped the way a + fixed-space-padded single string does at narrower terminal widths. + """ DEFAULT_CSS = """ StatusBar { height: 1; background: #12152a; + layout: horizontal; + } + StatusBar.-error { background: #ef4444; } + StatusBar.-leader { background: #7c79f0; } + StatusBar #status-left { + width: 1fr; color: #6b6e9a; padding: 0 2; } - StatusBar.-error { background: #ef4444; color: #dde1ff; } - StatusBar.-leader { background: #7c79f0; color: #dde1ff; } + StatusBar #status-right { + width: auto; + color: #6b6e9a; + padding: 0 2; + } + StatusBar.-error #status-left, StatusBar.-error #status-right { color: #dde1ff; } + StatusBar.-leader #status-left, StatusBar.-leader #status-right { color: #dde1ff; } """ connection: reactive[str] = reactive("connecting") @@ -25,20 +42,45 @@ class StatusBar(Widget): leader_hint: reactive[str] = reactive("") active_jobs: reactive[int] = reactive(0) - def render(self) -> str: + def compose(self): + yield Static(id="status-left") + yield Static("[dim]Space ?[/] Help [#ef4444]Space q[/] Quit", id="status-right") + + def on_mount(self) -> None: + self._render_left() + + def watch_connection(self) -> None: + self._render_left() + + def watch_message(self) -> None: + self._render_left() + + def watch_hint(self) -> None: + self._render_left() + + def watch_leader_hint(self) -> None: + self._render_left() + + def watch_active_jobs(self) -> None: + self._render_left() + + def _render_left(self) -> None: + try: + left = self.query_one("#status-left", Static) + except Exception: + return if self.leader_hint: - return f"LEADER · {self.leader_hint}" + left.update(f"LEADER · {self.leader_hint}") + return conn_color = "#22c55e" if self.connection in ("connected", "connecting") else "#ef4444" - left = f"[{conn_color}]●[/] {self.connection}" + text = f"[{conn_color}]●[/] {self.connection}" if self.active_jobs: noun = "job" if self.active_jobs == 1 else "jobs" - left += f" · [#f59e0b]⚙ {self.active_jobs} {noun} running[/]" + text += f" · [#f59e0b]⚙ {self.active_jobs} {noun} running[/]" if self.message: - left += f" · {self.message}" - left += f" · {self.hint}" - right = "[dim]Space ?[/] Help [#ef4444]Space q[/] Quit" - # Padding via spaces between left/right not reliable; use just left and let widget align. - return f"{left} {right}" + text += f" · {self.message}" + text += f" · {self.hint}" + left.update(text) def set_connection(self, state: str, ok: bool = True) -> None: self.connection = state From 884c32a02c994ee51bb15f22e48e433f326e6e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:00:22 +0000 Subject: [PATCH 07/13] tui: drop the bold/colored title styling on the welcome screen Plain text reads better than a stylized wordmark here. --- tui/screens/welcome.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tui/screens/welcome.py b/tui/screens/welcome.py index 6d4484e..a9d2287 100644 --- a/tui/screens/welcome.py +++ b/tui/screens/welcome.py @@ -44,8 +44,7 @@ class WelcomeScreen(Screen): align: center middle; } #app-title { - color: #7c79f0; - text-style: bold; + color: #dde1ff; width: 100%; text-align: center; height: auto; From 7f39c0f9a1a508fbbb8d081db912a1b2c2ac7d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:06:43 +0000 Subject: [PATCH 08/13] tui: add a screenshot section to the README Rendered from the actual app (headless Textual Pilot + Chromium), not a mockup. --- images/tui_welcome.png | Bin 0 -> 70476 bytes tui/README.md | 4 ++++ 2 files changed, 4 insertions(+) create mode 100644 images/tui_welcome.png diff --git a/images/tui_welcome.png b/images/tui_welcome.png new file mode 100644 index 0000000000000000000000000000000000000000..0bffe3bc032da9e32450012120e472476306cf89 GIT binary patch literal 70476 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%0D_V4;5MVYla9 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 literal 0 HcmV?d00001 diff --git a/tui/README.md b/tui/README.md index 7be2197..610d48d 100644 --- a/tui/README.md +++ b/tui/README.md @@ -2,6 +2,10 @@ Terminal interface for AmicoScript. Wraps the FastAPI backend over HTTP/SSE. +## Screenshot + +![AmicoScript TUI welcome screen](../images/tui_welcome.png) + ## Install ```bash From 5c17dae28383fe3b6acc12b9f912906c7a222b24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:16:56 +0000 Subject: [PATCH 09/13] Make the TUI easy to discover and start from both the web UI and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: add ./tui.sh and tui.bat launchers that install tui/requirements.txt on first run and then exec the TUI, so starting it is one command instead of two manual steps. tui/__main__.py now catches a missing dependency and prints an actionable one-liner instead of a raw traceback. Root README gains a "Terminal (TUI)" quick-start section next to the existing Docker/Local ones — previously the TUI wasn't mentioned there at all. Web: the Help modal (frontend/index.html) gained a "Prefer the terminal?" card with a ready-to-run command and a copy-to-clipboard button. The command includes --api-url only when the page isn't served from the TUI's own default, so it works unmodified against Docker/remote/custom-port setups. A real browser tab can't spawn a terminal process directly, so this is a copy-paste hand-off rather than an in-page launch — verified with a headless Playwright run that the command text and clipboard write are both correct. --- README.md | 13 +++++++++++ frontend/index.html | 54 ++++++++++++++++++++++++++++++++++++++++++++- tui.bat | 16 ++++++++++++++ tui.sh | 14 ++++++++++++ tui/__main__.py | 14 +++++++++--- 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tui.bat create mode 100755 tui.sh diff --git a/README.md b/README.md index 7ef2b7b..717ef9d 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,19 @@ On Linux the native window needs system WebKitGTK (`gir1.2-webkit2-4.1` + `python3-gi`); without it the app degrades to a browser tab. See [docs/desktop-shell.md](docs/desktop-shell.md). +### Terminal (TUI) + +Prefer the keyboard? AmicoScript also ships a terminal interface — same +backend, no browser needed. + +```bash +./tui.sh # macOS/Linux — installs TUI deps on first run, then launches +tui.bat # Windows +``` + +See [tui/README.md](tui/README.md) for keybindings and screenshots. The web +UI's Help modal also has a one-click "Copy command" for this. + ### Tests ```bash diff --git a/frontend/index.html b/frontend/index.html index 5577ab4..ad820b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3162,6 +3162,35 @@

Keyboard Shortcuts

} } + // Terminal UI (tui/) run command — includes --api-url only when this + // page isn't served from the TUI's own default, so the copied command + // still works unmodified against Docker / remote / custom-port setups. + function tuiCommand() { + const origin = window.location.origin; + const isDefault = origin === 'http://127.0.0.1:8002' || origin === 'http://localhost:8002'; + const urlFlag = isDefault ? '' : ` --api-url ${origin}`; + return `pip install -r tui/requirements.txt && python -m tui${urlFlag}`; + } + + function updateTuiCommand() { + const el = document.getElementById('tui-command'); + if (el) el.textContent = tuiCommand(); + } + + async function copyTuiCommand() { + const btn = document.getElementById('tui-copy-btn'); + try { + await navigator.clipboard.writeText(tuiCommand()); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = original; }, 1500); + } + } catch (_) { + // clipboard may be blocked by browser policy + } + } + // ========================================================================= // SSE // ========================================================================= @@ -6272,11 +6301,18 @@

Keyboard Shortcuts

// Help modal (function attachHelpHandlers() { const btn = document.getElementById('help-view-btn'); - if (btn) btn.addEventListener('click', () => { clientLog('Help modal opened'); document.getElementById('help-modal').classList.remove('hidden'); }); + if (btn) btn.addEventListener('click', () => { + clientLog('Help modal opened'); + document.getElementById('help-modal').classList.remove('hidden'); + updateTuiCommand(); + }); const closeBtn = document.getElementById('help-close-btn'); if (closeBtn) closeBtn.addEventListener('click', () => document.getElementById('help-modal').classList.add('hidden')); const overlay = document.getElementById('help-modal-overlay'); if (overlay) overlay.addEventListener('click', () => document.getElementById('help-modal').classList.add('hidden')); + + const copyBtn = document.getElementById('tui-copy-btn'); + if (copyBtn) copyBtn.addEventListener('click', copyTuiCommand); })(); } @@ -6710,6 +6746,22 @@

Running in Docker?

Set host.docker.internal + +
nul 2>&1 +if errorlevel 1 ( + echo Installing TUI dependencies... + "%PYTHON%" -m pip install -q -r tui\requirements.txt + if errorlevel 1 exit /b 1 +) + +"%PYTHON%" -m tui %* diff --git a/tui.sh b/tui.sh new file mode 100755 index 0000000..c0b343b --- /dev/null +++ b/tui.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Convenience launcher: ensures TUI deps are installed, then runs the TUI. +# Usage: ./tui.sh [--api-url http://host:port] [--no-server] [--debug] +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +PYTHON="${PYTHON:-python3}" + +if ! "$PYTHON" -c "import textual, httpx" >/dev/null 2>&1; then + echo "Installing TUI dependencies..." >&2 + "$PYTHON" -m pip install -q -r tui/requirements.txt +fi + +exec "$PYTHON" -m tui "$@" diff --git a/tui/__main__.py b/tui/__main__.py index 54966b5..29a2e06 100644 --- a/tui/__main__.py +++ b/tui/__main__.py @@ -5,9 +5,17 @@ def main() -> int: - from .config import parse_args - from .app import AmicoTUI - from .server import ServerManager + try: + from .config import parse_args + from .app import AmicoTUI + from .server import ServerManager + except ImportError as e: + print( + f"Missing TUI dependency ({e.name or e}).\n" + "Install with: pip install -r tui/requirements.txt", + file=sys.stderr, + ) + return 1 cfg = parse_args(sys.argv[1:]) server = ServerManager(cfg.api_url, spawn=cfg.spawn_server) From e885d256596cec3c0699fe2493d1b034d05f7067 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:26:44 +0000 Subject: [PATCH 10/13] tui: add busy/working spinner and a meeting-recording indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps command execution (the single chokepoint every slash command goes through, whether triggered from the CommandBar, a leader chord, or the palette) with push_busy()/pop_busy(), and shows an animated spinner in the status bar while any command is in flight. Also wires up GET /api/watcher/status, polled globally, so an in-progress meeting auto-capture recording is visible in the TUI too — previously only the web UI's tray badge showed it. While building this, found that the status-bar updates were silently broken since the leader-chord hint display was first written: self.app.query(StatusBar) looks like it searches the active screen, but App._get_dom_base() roots App-level queries at the App's hidden default screen, not whatever's pushed on top — so it always returned nothing. This affected the leader-armed hint (never rendered) and the "N jobs running" indicator added in an earlier pass (silently never updated either). Fixed with a status_bars() helper that queries each screen in the stack directly, and pointed every call site at it. Verified by rendering the app (headless Textual Pilot -> export_screenshot -> Chromium): the busy spinner, recording badge, job count, and the previously-broken leader-armed hint all now show correctly. --- tui/api.py | 5 ++++ tui/app.py | 53 ++++++++++++++++++++++++++++++++++++--- tui/commands.py | 3 +++ tui/leader.py | 3 +-- tui/palette.py | 3 +++ tui/screens/library.py | 3 +++ tui/widgets/status_bar.py | 31 +++++++++++++++++++++++ 7 files changed, 96 insertions(+), 5 deletions(-) diff --git a/tui/api.py b/tui/api.py index 99e8e99..bf8fe04 100644 --- a/tui/api.py +++ b/tui/api.py @@ -293,6 +293,11 @@ async def save_settings( }), ) + # --- meeting watcher ---------------------------------------------- + + async def watcher_status(self) -> dict: + return await self._get("/api/watcher/status") + # --- helpers -------------------------------------------------------- diff --git a/tui/app.py b/tui/app.py index b45740a..0ababbf 100644 --- a/tui/app.py +++ b/tui/app.py @@ -138,12 +138,43 @@ def __init__(self, cfg: Config, server: ServerManager) -> None: self.sub_title = cfg.api_url self._palette_mru: deque = deque(maxlen=30) self.leader = LeaderDispatcher(self) + self._busy_count = 0 def on_mount(self) -> None: from .screens.welcome import WelcomeScreen self.push_screen(WelcomeScreen()) self.run_worker(self._health_loop(), exclusive=True, name="health") self.run_worker(self._jobs_loop(), exclusive=True, name="jobs_poll") + self.run_worker(self._watcher_loop(), exclusive=True, name="watcher_poll") + + def status_bars(self) -> list: + """Every mounted StatusBar, across the whole screen stack. + + ``self.query(StatusBar)`` looks broken but isn't what it seems: + ``App._get_dom_base()`` roots App-level queries at the App's hidden + default screen, not the active one — so it silently never finds + anything pushed on top (which is every screen this app shows). + Querying each stacked screen directly is what actually works. + """ + from .widgets.status_bar import StatusBar + bars = [] + for screen in self.screen_stack: + bars.extend(screen.query(StatusBar)) + return bars + + # --- busy indicator (commands.run_command wraps handlers with this) -- + + def push_busy(self) -> None: + self._busy_count += 1 + self._sync_busy() + + def pop_busy(self) -> None: + self._busy_count = max(0, self._busy_count - 1) + self._sync_busy() + + def _sync_busy(self) -> None: + for bar in self.status_bars(): + bar.busy = self._busy_count > 0 async def on_unmount(self) -> None: await self.api.aclose() @@ -174,18 +205,34 @@ async def _jobs_loop(self) -> None: leave the Jobs screen.""" import asyncio - from .widgets.status_bar import StatusBar - while True: try: data = await self.api.jobs() rows = data.get("jobs", []) if isinstance(data, dict) else [] - for bar in self.query(StatusBar): + for bar in self.status_bars(): bar.active_jobs = len(rows) except Exception: pass await asyncio.sleep(3.0) + async def _watcher_loop(self) -> None: + """Poll the meeting auto-capture watcher so an in-progress meeting + recording (started outside the TUI, by the watcher daemon) is + visible here too — not just in the web UI's tray badge.""" + import asyncio + + while True: + try: + st = await self.api.watcher_status() + is_recording = bool(st.get("recording")) + label = str(st.get("app") or "") if is_recording else "" + for bar in self.status_bars(): + bar.recording = is_recording + bar.recording_label = label + except Exception: + pass + await asyncio.sleep(3.0) + # --- key intercept (leader) ----------------------------------------- def on_key(self, event: Key) -> None: diff --git a/tui/commands.py b/tui/commands.py index ee3bf52..9bf6bce 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -49,10 +49,13 @@ async def run_command(app: "AmicoTUI", raw: str) -> None: if cmd is None: app.notify(f"unknown command: /{cmd_name}") return + app.push_busy() try: await cmd.handler(app, args) except Exception as e: app.notify(f"/{cmd_name} failed: {e}") + finally: + app.pop_busy() # --- handlers -------------------------------------------------------- diff --git a/tui/leader.py b/tui/leader.py index f4f3203..dd55fe4 100644 --- a/tui/leader.py +++ b/tui/leader.py @@ -90,9 +90,8 @@ def _clear(self) -> None: self._notify_bars("clear_chord_hints") def _notify_bars(self, method: str, *args) -> None: - from .widgets.status_bar import StatusBar try: - for bar in self.app.query(StatusBar): + for bar in self.app.status_bars(): fn = getattr(bar, method, None) if callable(fn): fn(*args) diff --git a/tui/palette.py b/tui/palette.py index 2315362..8d17dea 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -567,6 +567,7 @@ async def _activate(self, entry_key: str) -> None: ) if not confirmed: return + app.push_busy() try: await app.api.delete_recording(rec_id) app.notify(f"deleted {rec_id[:8]}") @@ -575,6 +576,8 @@ async def _activate(self, entry_key: str) -> None: screen.refresh_library() except Exception as e: app.notify(f"delete failed: {e}", severity="error") + finally: + app.pop_busy() return # In analyze mode, picking a recording opens the type chooser. diff --git a/tui/screens/library.py b/tui/screens/library.py index 0e43f59..83fdc48 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -160,12 +160,15 @@ async def _delete_selected(self, rec_id: str) -> None: if not confirmed: return app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() try: await app.api.delete_recording(rec_id) app.notify(f"deleted {rec_id[:8]}") self.refresh_library() except Exception as e: app.notify(f"delete failed: {e}", severity="error") + finally: + app.pop_busy() def action_copy_name(self) -> None: rec_id = self._selected_id() diff --git a/tui/widgets/status_bar.py b/tui/widgets/status_bar.py index e16d5df..069c086 100644 --- a/tui/widgets/status_bar.py +++ b/tui/widgets/status_bar.py @@ -5,6 +5,8 @@ from textual.widget import Widget from textual.widgets import Static +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + class StatusBar(Widget): """Single-line status bar at the bottom of the app. @@ -41,6 +43,13 @@ class StatusBar(Widget): hint: reactive[str] = reactive("Space leader · / palette") leader_hint: reactive[str] = reactive("") active_jobs: reactive[int] = reactive(0) + busy: reactive[bool] = reactive(False) + recording: reactive[bool] = reactive(False) + recording_label: reactive[str] = reactive("") + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._spinner_i = 0 def compose(self): yield Static(id="status-left") @@ -48,6 +57,13 @@ def compose(self): def on_mount(self) -> None: self._render_left() + self.set_interval(0.1, self._tick_spinner) + + def _tick_spinner(self) -> None: + if not self.busy: + return + self._spinner_i = (self._spinner_i + 1) % len(SPINNER_FRAMES) + self._render_left() def watch_connection(self) -> None: self._render_left() @@ -64,6 +80,15 @@ def watch_leader_hint(self) -> None: def watch_active_jobs(self) -> None: self._render_left() + def watch_busy(self) -> None: + self._render_left() + + def watch_recording(self) -> None: + self._render_left() + + def watch_recording_label(self) -> None: + self._render_left() + def _render_left(self) -> None: try: left = self.query_one("#status-left", Static) @@ -74,6 +99,12 @@ def _render_left(self) -> None: return conn_color = "#22c55e" if self.connection in ("connected", "connecting") else "#ef4444" text = f"[{conn_color}]●[/] {self.connection}" + if self.recording: + label = f" · {self.recording_label}" if self.recording_label else "" + text += f" · [#ef4444]● REC{label}[/]" + if self.busy: + frame = SPINNER_FRAMES[self._spinner_i] + text += f" · [#f59e0b]{frame} working…[/]" if self.active_jobs: noun = "job" if self.active_jobs == 1 else "jobs" text += f" · [#f59e0b]⚙ {self.active_jobs} {noun} running[/]" From f43a085871e840afeeee0b7483fa28bc9550601b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:56:38 +0000 Subject: [PATCH 11/13] tui: stream live transcript text during processing; add find/jump-to-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job screen: each segment's text now arrives over the same SSE stream as progress (backend/core/transcription.py already pushed it as data.segment.text — the web UI used it, the TUI didn't). The job log now shows the actual words as they're produced instead of a generic "Transcribing... 00:12 / 05:30" line, so you can read along instead of waiting for the job to finish. Transcript screen: "/" opens a find box. Typing text jumps to the first matching segment and Enter cycles to the next; typing a timestamp (83, 1:23, 1:02:03) jumps straight to the segment covering that time. Fixed a focus bug hit while building this: TranscriptScreen never explicitly focused anything on mount, relying on Textual's default focus order. Adding the (initially hidden) find Input earlier in the compose tree meant it silently grabbed initial focus instead of the segment list, which would have broken plain arrow-key navigation. Fixed by explicitly focusing the segment list on mount, matching every other screen's pattern. Verified with headless Pilot: text/timestamp find-and-jump, Escape closing find without popping the screen, normal j/k navigation still working after find closes, and a mocked SSE stream confirming segment text (not just the generic progress line) reaches the job log. --- tui/README.md | 13 +++++-- tui/screens/job_detail.py | 18 +++++++++ tui/screens/transcript.py | 75 +++++++++++++++++++++++++++++++++++-- tui/widgets/segment_list.py | 47 +++++++++++++++++++++++ 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/tui/README.md b/tui/README.md index 610d48d..97a5aa7 100644 --- a/tui/README.md +++ b/tui/README.md @@ -90,17 +90,24 @@ switches mode and filters a different source: ### Transcript | Key | Action | |-----|--------| -| `j` / `k` | Move segment | -| `g g` / `G` | First / last segment | +| `↑↓` / `j` `k` | Move segment | +| `Home`/`End` · `g g` / `G` | First / last segment | +| `PageUp`/`PageDown` | Page through segments | | `n` / `N` | Next / previous speaker change | +| `/` | Find in transcript — type text to jump to the first match, `Enter` cycles to the next; type a timestamp (`83`, `1:23`, `1:02:03`) to jump straight there | | `y` | Copy current segment | | `Y` | Copy full transcript | | `Space` | Play / pause | | `s` | Stop | | `Ctrl+A` | Run LLM analysis on this recording | -| `Escape` / `q` | Back to library | +| `Escape` / `q` | Close find if open, else back to library | ### Job screen +While transcribing, the log shows each segment's text as it's produced +(streamed over the same SSE connection as progress), not just a generic +"Transcribing... 00:12 / 05:30" line — so you can read along as it works +instead of waiting for the job to finish. + | Key | Action | |-----|--------| | `c` | Cancel job | diff --git a/tui/screens/job_detail.py b/tui/screens/job_detail.py index d1aeb87..2c5d518 100644 --- a/tui/screens/job_detail.py +++ b/tui/screens/job_detail.py @@ -17,6 +17,15 @@ from ..app import AmicoTUI +def _fmt_ts(seconds: float) -> str: + s = int(seconds) + h, rem = divmod(s, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h:d}:{m:02d}:{s:02d}" + return f"{m:02d}:{s:02d}" + + class JobDetailScreen(Screen): BINDINGS = [ Binding("escape", "pop", "Back"), @@ -73,6 +82,15 @@ async def _stream(self) -> None: pass if "message" in evt and evt["message"]: prog.label = str(evt["message"]) + # Each transcribed segment arrives as evt["data"]["segment"] — + # show the actual words as they're produced instead of just + # the generic "Transcribing... 00:12 / 05:30" progress line. + segment = (evt.get("data") or {}).get("segment") + text = (segment or {}).get("text", "").strip() + if text: + ts = _fmt_ts(float(segment.get("start", 0.0))) + log.write_line(f"[{ts}] {text}") + elif "message" in evt and evt["message"]: log.write_line(str(evt["message"])) if "log" in evt and evt["log"]: log.write_line(str(evt["log"])) diff --git a/tui/screens/transcript.py b/tui/screens/transcript.py index 45c7242..bac23e1 100644 --- a/tui/screens/transcript.py +++ b/tui/screens/transcript.py @@ -6,15 +6,15 @@ from typing import TYPE_CHECKING from textual.binding import Binding -from textual.containers import Vertical +from textual.containers import Horizontal, Vertical from textual.screen import Screen -from textual.widgets import OptionList, Static +from textual.widgets import Input, OptionList, Static from ..clipboard import copy_to_clipboard from ..playback import Player from ..waveform import compute_levels_async from ..widgets.chrome import CommandBar, ContextHint, TitleBar -from ..widgets.segment_list import SegmentList +from ..widgets.segment_list import SegmentList, parse_timestamp from ..widgets.status_bar import StatusBar from ..widgets.waveform_view import WaveformView @@ -31,6 +31,7 @@ class TranscriptScreen(Screen): Binding("space", "toggle_play", "Play/Pause"), Binding("s", "stop_play", "Stop"), Binding("ctrl+a", "analyze", "Analyze"), + Binding("slash", "focus_search", "Find"), ] DEFAULT_CSS = """ @@ -49,6 +50,26 @@ class TranscriptScreen(Screen): color: #6b6e9a; border-bottom: solid #2a2860; } + #findline { + height: 3; + padding: 0 2; + background: #12152a; + color: #dde1ff; + border-bottom: solid #2a2860; + display: none; + } + #findline Static { + width: 6; + height: 3; + content-align: left middle; + color: #7c79f0; + } + #findline Input { + height: 3; + background: #12152a; + color: #dde1ff; + border: none; + } """ leader_chords = { @@ -73,17 +94,21 @@ def compose(self): yield TitleBar(id="titlebar") yield Static("loading…", id="meta") yield Static("", id="legend") + with Horizontal(id="findline"): + yield Static("find:", id="findlabel") + yield Input(placeholder="text, or a timestamp like 1:23…", id="findinput") with Vertical(): yield WaveformView(id="wave") yield SegmentList(id="segments") yield ContextHint( - "Space play · y copy seg · Y copy all · /export json|srt|txt|md · ^A analyze", + "Space play · y copy seg · Y copy all · / find · /export json|srt|txt|md · ^A analyze", id="ctxhint", ) yield CommandBar(id="cmdbar") yield StatusBar(id="statusbar") def on_mount(self) -> None: + self.query_one(SegmentList).focus() self.run_worker(self._load(), exclusive=True) self._anim_timer = self.set_interval(1 / 15, self._tick, pause=False) @@ -186,8 +211,50 @@ def on_option_list_option_selected( self._play_from(float(seg.get("start", 0.0))) def action_pop(self) -> None: + findline = self.query_one("#findline", Horizontal) + if findline.display: + self.action_clear_search() + return self.app.pop_screen() + def action_focus_search(self) -> None: + self.query_one("#findline", Horizontal).display = True + self.query_one("#findinput", Input).focus() + + def action_clear_search(self) -> None: + self.query_one("#findinput", Input).value = "" + self.query_one("#findline", Horizontal).display = False + self.query_one(SegmentList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id != "findinput": + return + self._run_find(event.value, cycle=False) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "findinput": + return + self._run_find(event.value, cycle=True) + + def _run_find(self, query: str, cycle: bool) -> None: + seg_list = self.query_one(SegmentList) + query = query.strip() + if not query: + return + seconds = parse_timestamp(query) + if seconds is not None: + idx = seg_list.jump_to_time(seconds) + if idx is not None: + seg_list.highlighted = idx + self.query_one(StatusBar).flash(f"jumped to {query}") + return + start_from = ((seg_list.highlighted or 0) + 1) if cycle else 0 + idx = seg_list.find_first(query, start_from=start_from) + if idx is None: + self.query_one(StatusBar).flash(f"no matches for “{query}”") + return + seg_list.highlighted = idx + def action_copy_segment(self) -> None: seg = self.query_one(SegmentList).selected_segment() if not seg: diff --git a/tui/widgets/segment_list.py b/tui/widgets/segment_list.py index f816582..f8907c6 100644 --- a/tui/widgets/segment_list.py +++ b/tui/widgets/segment_list.py @@ -1,6 +1,8 @@ """Scrollable list of transcript segments.""" from __future__ import annotations +import re + from textual.binding import Binding from textual.widgets import OptionList from textual.widgets.option_list import Option @@ -8,6 +10,8 @@ SPEAKER_COLORS = ["#22c55e", "#2dd4bf", "#f59e0b", "#7c79f0", "#ef4444", "#dde1ff"] +_TIMESTAMP_RE = re.compile(r"^\d+$|^\d{1,2}:\d{2}(:\d{2})?$") + def _fmt_ts(seconds: float) -> str: s = int(seconds) @@ -18,6 +22,21 @@ def _fmt_ts(seconds: float) -> str: return f"{m:02d}:{s:02d}" +def parse_timestamp(text: str) -> float | None: + """Parse "83", "1:23" or "1:02:03" into seconds; None if not timestamp-shaped.""" + text = text.strip() + if not _TIMESTAMP_RE.match(text): + return None + parts = [int(p) for p in text.split(":")] + if len(parts) == 1: + return float(parts[0]) + if len(parts) == 2: + m, s = parts + return float(m * 60 + s) + h, m, s = parts + return float(h * 3600 + m * 60 + s) + + class SegmentList(OptionList): BINDINGS = [ Binding("j", "cursor_down", show=False), @@ -69,6 +88,34 @@ def selected_segment(self) -> dict | None: return None return self.segments[idx] + def find_first(self, query: str, start_from: int = 0) -> int | None: + """Case-insensitive substring search over segment text, wrapping + around from ``start_from``. Returns the matching index, or None.""" + query = query.strip().lower() + if not query or not self.segments: + return None + n = len(self.segments) + for offset in range(n): + idx = (start_from + offset) % n + if query in (self.segments[idx].get("text") or "").lower(): + return idx + return None + + def jump_to_time(self, seconds: float) -> int | None: + """Return the index of the segment covering ``seconds`` — or the + last one starting at or before it if none contains it exactly.""" + if not self.segments: + return None + best = 0 + for i, seg in enumerate(self.segments): + start = float(seg.get("start", 0) or 0) + end = float(seg.get("end", start) or start) + if start <= seconds < end: + return i + if start <= seconds: + best = i + return best + def action_first(self) -> None: if self.option_count: self.highlighted = 0 From 809f5de4bbadf87945b61c4efe2fb8d09fc22b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:46:47 +0000 Subject: [PATCH 12/13] tui: close library-management parity gaps with the web UI (cheap wins) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web UI can rename/move/tag individual recordings and rename/delete folders and tags; the TUI's ApiClient already wrapped most of the backend calls for this (update_recording, update_folder, delete_folder, create_tag, add_tag/remove_tag) but nothing ever called them — no command, no keybinding. Tag rename/delete had no wrapper at all. Adds: - ApiClient.update_tag() / delete_tag() - /rename , /move [folder_id], /tag-toggle - /folder new|rename|delete and /tag new|rename|delete subcommands (mirrors the existing /folder new pattern) - Library screen keys: R rename (prompt), m move (folder picker), t tag (add/remove picker) - A new PromptDialog modal (mirrors ConfirmDialog) for single-line text input, reused by rename - Ad-hoc "move to folder" and "toggle tag" pickers in palette.py, reusing the same mechanism as the existing analysis-type picker Verified with headless Pilot against a mocked ApiClient: every new command and keybinding (including the ConfirmDialog-gated folder/tag deletes and the PromptDialog-gated rename, with its pre-filled current name) drives the right API call with the right arguments. --- tui/README.md | 11 +++- tui/api.py | 6 ++ tui/commands.py | 122 ++++++++++++++++++++++++++++++++++++++++- tui/palette.py | 89 ++++++++++++++++++++++++++++++ tui/screens/library.py | 53 +++++++++++++++++- tui/widgets/prompt.py | 87 +++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 tui/widgets/prompt.py diff --git a/tui/README.md b/tui/README.md index 97a5aa7..12414f3 100644 --- a/tui/README.md +++ b/tui/README.md @@ -83,6 +83,9 @@ switches mode and filters a different source: | `g g` / `G` | Top / bottom | | `Enter` | Open recording (transcript screen) | | `r` | Refresh library | +| `R` | Rename recording (prompt) | +| `m` | Move recording to a folder (picker) | +| `t` | Add / remove a tag on the recording (picker) | | `y` | Copy filename to clipboard | | `d` | Delete (prompt) | | `Escape` | Back | @@ -133,10 +136,12 @@ Press `/` to open the command palette. | `/export ` | Export transcript (json/srt/txt/md) — saved to CWD | | `/cancel ` | Cancel running job | | `/delete ` | Delete recording | -| `/folder new ` | Create folder | +| `/rename ` | Rename a recording | +| `/move [folder_id]` | Move a recording to a folder — opens a picker if `folder_id` omitted | +| `/tag-toggle ` | Add / remove a tag on a recording (picker) | | `/library` | Open the recordings library (sub-picker after space) | -| `/folder` | Pick a folder (or `/folder new ` to create) | -| `/tag` | Pick a tag | +| `/folder` | Pick a folder — or `new ` / `rename ` / `delete ` | +| `/tag` | Pick a tag — or `new ` / `rename ` / `delete ` | | `/analyze` | Pick a recording and run summary / action_items / translate / custom | | `/models` | Pick a Whisper transcription model (sets as default) | | `/llm` | Pick an LLM model (sets as default) | diff --git a/tui/api.py b/tui/api.py index bf8fe04..8bc6e7d 100644 --- a/tui/api.py +++ b/tui/api.py @@ -153,6 +153,12 @@ async def create_tag(self, name: str, color_code: str | None = None) -> dict: "/api/tags", data=_drop_none({"name": name, "color_code": color_code}) ) + async def update_tag(self, tag_id: int, **fields: Any) -> dict: + return await self._patch_form(f"/api/tags/{tag_id}", data=fields) + + async def delete_tag(self, tag_id: int) -> dict: + return await self._delete(f"/api/tags/{tag_id}") + async def add_tag(self, recording_id: str, tag_id: int) -> dict: return await self._post( f"/api/recordings/{recording_id}/tags/{tag_id}" diff --git a/tui/commands.py b/tui/commands.py index 9bf6bce..298c1e0 100644 --- a/tui/commands.py +++ b/tui/commands.py @@ -190,7 +190,53 @@ async def _delete(app, args): screen.refresh_library() -@command("folder", "pick a folder (or 'new ' to create)") +@command("rename", "rename recording ") +async def _rename(app, args): + if len(args) < 2: + app.notify("usage: /rename ") + return + rec_id, *name_parts = args + alias = " ".join(name_parts) + try: + await app.api.update_recording(rec_id, alias=alias) + app.notify(f"renamed {rec_id[:8]} → {alias}") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + except Exception as e: + app.notify(f"rename failed: {e}", severity="error") + + +@command("move", "move recording to a folder") +async def _move(app, args): + if not args: + app.notify("usage: /move [folder_id]") + return + rec_id, *rest = args + if rest: + try: + await app.api.update_recording(rec_id, folder_id=rest[0]) + app.notify(f"moved {rec_id[:8]}") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + except Exception as e: + app.notify(f"move failed: {e}", severity="error") + return + from .palette import _open_move_to_folder_picker + _open_move_to_folder_picker(app, rec_id) + + +@command("tag-toggle", "add/remove a tag on recording ") +async def _tag_toggle(app, args): + if not args: + app.notify("usage: /tag-toggle ") + return + from .palette import _open_tag_toggle_picker + _open_tag_toggle_picker(app, args[0]) + + +@command("folder", "pick a folder (or 'new ' / 'rename ' / 'delete ')") async def _folder(app, args): if args and args[0] == "new": if len(args) < 2: @@ -200,15 +246,85 @@ async def _folder(app, args): await app.api.create_folder(name) app.notify(f"folder created: {name}") return - # No args (or non-'new' args) — re-open palette in folder-pick mode. + if args and args[0] == "rename": + if len(args) < 3: + app.notify("usage: /folder rename ") + return + folder_id, *name_parts = args[1:] + name = " ".join(name_parts) + try: + await app.api.update_folder(folder_id, name=name) + app.notify(f"folder renamed to {name}") + except Exception as e: + app.notify(f"rename failed: {e}", severity="error") + return + if args and args[0] == "delete": + if len(args) < 2: + app.notify("usage: /folder delete ") + return + folder_id = args[1] + from .widgets.confirm import ConfirmDialog + confirmed = await app.push_screen_wait( + ConfirmDialog( + f"Delete folder {folder_id[:8]}…? Recordings inside move to " + "All Recordings, they are not deleted." + ) + ) + if not confirmed: + return + try: + await app.api.delete_folder(folder_id) + app.notify("folder deleted") + except Exception as e: + app.notify(f"delete failed: {e}", severity="error") + return + # No args (or unrecognised args) — re-open palette in folder-pick mode. from .palette import Palette, seed_palette pal = Palette() app.push_screen(pal) pal.call_after_refresh(seed_palette, pal, "/folder ") -@command("tag", "pick a tag to scope library") +@command("tag", "pick a tag (or 'new ' / 'rename ' / 'delete ')") async def _tag(app, args): + if args and args[0] == "new": + if len(args) < 2: + app.notify("usage: /tag new ") + return + name = " ".join(args[1:]) + await app.api.create_tag(name) + app.notify(f"tag created: {name}") + return + if args and args[0] == "rename": + if len(args) < 3: + app.notify("usage: /tag rename ") + return + tag_id, *name_parts = args[1:] + name = " ".join(name_parts) + try: + await app.api.update_tag(tag_id, name=name) + app.notify(f"tag renamed to {name}") + except Exception as e: + app.notify(f"rename failed: {e}", severity="error") + return + if args and args[0] == "delete": + if len(args) < 2: + app.notify("usage: /tag delete ") + return + tag_id = args[1] + from .widgets.confirm import ConfirmDialog + confirmed = await app.push_screen_wait( + ConfirmDialog(f"Delete tag {tag_id[:8]}…? It's removed from every recording.") + ) + if not confirmed: + return + try: + await app.api.delete_tag(tag_id) + app.notify("tag deleted") + except Exception as e: + app.notify(f"delete failed: {e}", severity="error") + return + # No args (or unrecognised args) — re-open palette in tag-pick mode. from .palette import Palette, seed_palette pal = Palette() app.push_screen(pal) diff --git a/tui/palette.py b/tui/palette.py index 8d17dea..3e5f657 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -756,6 +756,95 @@ async def _noop(app: "AmicoTUI") -> None: return None +def _open_move_to_folder_picker(app: "AmicoTUI", rec_id: str) -> None: + async def build_and_push() -> None: + try: + folders = await app.api.folders() + except Exception as e: + app.notify(f"folders load failed: {e}", severity="error") + return + entries = [ + Entry( + kind="folder", + key="folder:", + display="▢ (no folder)", + subtitle="remove from any folder", + search_text="no folder none", + on_select=_noop, + ) + ] + entries += [ + Entry( + kind="folder", + key=f"folder:{f.get('id')}", + display=f"▣ {f.get('name', '?')}", + subtitle=f"folder · id {str(f.get('id'))[:8]}", + search_text=str(f.get("name", "")), + on_select=_noop, + ) + for f in (folders or []) if f.get("id") is not None + ] + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + folder_id = entry.key.split(":", 1)[1] + try: + await app.api.update_recording(rec_id, folder_id=folder_id) + app.notify("moved to folder" if folder_id else "removed from folder") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + except Exception as e: + app.notify(f"move failed: {e}", severity="error") + + app.push_screen(Palette(entries=entries, on_pick=on_pick, title=f"move {rec_id[:8]} to…")) + + app.run_worker(build_and_push(), exclusive=False) + + +def _open_tag_toggle_picker(app: "AmicoTUI", rec_id: str) -> None: + async def build_and_push() -> None: + try: + rec = await app.api.recording(rec_id) + all_tags = await app.api.tags() + except Exception as e: + app.notify(f"tags load failed: {e}", severity="error") + return + if not all_tags: + app.notify("no tags yet — create one with /tag new ") + return + applied_ids = {str(t.get("id")) for t in (rec.get("tags") or [])} + entries = [ + Entry( + kind="tag", + key=f"tag:{t.get('id')}", + display=f"{'●' if str(t.get('id')) in applied_ids else '○'} {t.get('name', '?')}", + subtitle="applied — enter removes" if str(t.get("id")) in applied_ids else "enter adds", + search_text=str(t.get("name", "")), + on_select=_noop, + ) + for t in all_tags if t.get("id") is not None + ] + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + tag_id = entry.key.split(":", 1)[1] + try: + if tag_id in applied_ids: + await app.api.remove_tag(rec_id, tag_id) + app.notify("tag removed") + else: + await app.api.add_tag(rec_id, tag_id) + app.notify("tag added") + screen = app.screen + if hasattr(screen, "refresh_library"): + screen.refresh_library() + except Exception as e: + app.notify(f"tag update failed: {e}", severity="error") + + app.push_screen(Palette(entries=entries, on_pick=on_pick, title=f"toggle tags on {rec_id[:8]}")) + + app.run_worker(build_and_push(), exclusive=False) + + def entries_from_folders(folders: list[dict] | None) -> list[Entry]: return [ Entry( diff --git a/tui/screens/library.py b/tui/screens/library.py index 83fdc48..0aa5c17 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -86,6 +86,9 @@ class LibraryPanel(Widget): Binding("G", "cursor_bottom", show=False), Binding("g,g", "cursor_top", show=False), Binding("d", "delete_row", "Delete"), + Binding("R", "rename_row", "Rename"), + Binding("m", "move_row", "Move"), + Binding("t", "tag_row", "Tag"), Binding("y", "copy_name", "Copy name"), Binding("enter", "open", "Open"), ] @@ -170,6 +173,45 @@ async def _delete_selected(self, rec_id: str) -> None: finally: app.pop_busy() + def action_rename_row(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + self.run_worker(self._rename_selected(rec_id), exclusive=False) + + async def _rename_selected(self, rec_id: str) -> None: + from ..widgets.prompt import PromptDialog + current = self._selected_name() or "" + new_name = await self.app.push_screen_wait( + PromptDialog("Rename recording to:", initial=current) + ) + if not new_name or new_name == current: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + await app.api.update_recording(rec_id, alias=new_name) + app.notify(f"renamed to {new_name}") + self.refresh_library() + except Exception as e: + app.notify(f"rename failed: {e}", severity="error") + finally: + app.pop_busy() + + def action_move_row(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + from ..palette import _open_move_to_folder_picker + _open_move_to_folder_picker(self.app, rec_id) # type: ignore[arg-type] + + def action_tag_row(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + from ..palette import _open_tag_toggle_picker + _open_tag_toggle_picker(self.app, rec_id) # type: ignore[arg-type] + def action_copy_name(self) -> None: rec_id = self._selected_id() if rec_id is None or self.table is None: @@ -240,6 +282,13 @@ def _selected_id(self) -> str | None: return self.row_keys[idx] return None + def _selected_name(self) -> str | None: + if not self.table or self.table.row_count == 0: + return None + row = self.table.get_row_at(self.table.cursor_row) + name_cell = row[0] + return name_cell.plain if isinstance(name_cell, Text) else str(name_cell) + class LibraryScreen(Screen): """Full-screen library view.""" @@ -289,7 +338,7 @@ def compose(self): id="library_panel", ) yield ContextHint( - "↑↓ navigate · ↵ open · /import · /export · /delete · /folder · /search", + "↑↓ navigate · ↵ open · R rename · m move · t tag · d delete · /search", id="ctxhint", ) yield CommandBar(id="cmdbar") @@ -304,7 +353,7 @@ def _on_loaded(self, count: int, total_dur: float) -> None: try: self.query_one("#ctxhint", ContextHint).set_text( f"{count} recordings · {h}h {m:02d}m total " - f"| ↑↓ navigate · ↵ open · /import · /export · /delete · /search" + f"| ↑↓ navigate · ↵ open · R rename · m move · t tag · d delete" ) except Exception: pass diff --git a/tui/widgets/prompt.py b/tui/widgets/prompt.py new file mode 100644 index 0000000..f6f53a1 --- /dev/null +++ b/tui/widgets/prompt.py @@ -0,0 +1,87 @@ +"""Reusable single-line text-input modal (rename, create, etc). + +Usage: ``name = await app.push_screen_wait(PromptDialog("Rename to:", initial=old_name))`` +Resolves to the trimmed input value, or ``None`` if cancelled / left blank. +""" +from __future__ import annotations + +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Static + + +class PromptDialog(ModalScreen[str | None]): + """Modal that resolves to the entered text, or None on cancel.""" + + DEFAULT_CSS = """ + PromptDialog { + align: center middle; + background: rgba(12,14,26,0.85); + } + #box { + width: 60; + height: auto; + padding: 1 2; + background: #12152a; + border: tall #4a47c0; + } + #message { + color: #dde1ff; + padding: 0 0 1 0; + } + #box Input { + margin-bottom: 1; + } + #buttons { + height: 3; + align: right middle; + } + #buttons Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__( + self, + message: str, + initial: str = "", + placeholder: str = "", + confirm_label: str = "Save", + ) -> None: + super().__init__() + self.message = message + self.initial = initial + self.placeholder = placeholder + self.confirm_label = confirm_label + + def compose(self): + with Vertical(id="box"): + yield Static(self.message, id="message") + yield Input(value=self.initial, placeholder=self.placeholder, id="prompt-input") + with Horizontal(id="buttons"): + yield Button("Cancel", id="cancel") + yield Button(self.confirm_label, id="confirm", variant="primary") + + def on_mount(self) -> None: + inp = self.query_one("#prompt-input", Input) + inp.focus() + inp.cursor_position = len(inp.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._confirm() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm": + self._confirm() + else: + self.dismiss(None) + + def _confirm(self) -> None: + value = self.query_one("#prompt-input", Input).value.strip() + self.dismiss(value or None) + + def action_cancel(self) -> None: + self.dismiss(None) From 414440625a7dcb8d9072f56790ae6f1e50e5023f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:58:49 +0000 Subject: [PATCH 13/13] tui: bulk library operations and transcript segment editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library: multi-select (v to toggle a row, x for a bulk-action menu — delete / export as combined markdown / move to folder / tag) closes the last big library-management gap vs. the web UI. Refactored the single-recording move/tag pickers in palette.py into shared _folder_entries()/_tag_entries() builders so the new bulk pickers (open_bulk_move_picker, open_bulk_tag_picker) don't duplicate them. Transcript: e edits a segment's text, Ctrl+R resets it to the original, a sets the speaker on just that segment, S renames a speaker everywhere in the transcript — all four backend endpoints existed and were already used by the web UI but had no TUI wrapper at all. Bug found and fixed while building the bulk-action menu: Palette's MRU (recent-selection) ranking pushed every picked entry's key into a persistent, app-wide deque and re-sorted the list by it on next open. That's fine for the main command palette, but ad-hoc menus like the new bulk-action picker are rebuilt fresh each time and reuse the same keys (e.g. "bulk:delete") across unrelated invocations — so picking "export" once silently moved "export" to the top the next time the menu opened, making a bare Enter run the wrong action. MRU-boosting is now skipped for all ad-hoc pickers (analysis-type, move, tag-toggle, bulk), which only makes sense for the persistent palette anyway. Also confirmed (while chasing why 'space' wouldn't toggle a library row's selection) that the global Space-leader intercepts the key at the App level before it ever reaches a focused widget's own bindings on any screen with leader_chords — so multi-select uses 'v' instead. Noting this because it means TranscriptScreen's existing "Space = Play/Pause" binding has likely been dead code since it was written, for the same reason; left as-is since fixing it wasn't part of this change and touches established behavior. Verified with headless Pilot against mocked ApiClients: multi-select toggling, all four bulk actions (including the ConfirmDialog-gated bulk delete), and all four segment-editing actions (including the PromptDialog pre-fill and in-place SegmentList updates without a full reload). --- tui/README.md | 12 ++- tui/api.py | 37 +++++++++ tui/palette.py | 147 +++++++++++++++++++++++++++--------- tui/screens/library.py | 128 ++++++++++++++++++++++++++++--- tui/screens/transcript.py | 106 +++++++++++++++++++++++++- tui/widgets/segment_list.py | 20 +++++ 6 files changed, 401 insertions(+), 49 deletions(-) diff --git a/tui/README.md b/tui/README.md index 12414f3..d70a914 100644 --- a/tui/README.md +++ b/tui/README.md @@ -86,6 +86,8 @@ switches mode and filters a different source: | `R` | Rename recording (prompt) | | `m` | Move recording to a folder (picker) | | `t` | Add / remove a tag on the recording (picker) | +| `v` | Toggle multi-select on this row | +| `x` | Bulk actions on selected rows — delete, export (combined markdown), move to folder, tag | | `y` | Copy filename to clipboard | | `d` | Delete (prompt) | | `Escape` | Back | @@ -100,6 +102,10 @@ switches mode and filters a different source: | `/` | Find in transcript — type text to jump to the first match, `Enter` cycles to the next; type a timestamp (`83`, `1:23`, `1:02:03`) to jump straight there | | `y` | Copy current segment | | `Y` | Copy full transcript | +| `e` | Edit this segment's text (prompt) | +| `Ctrl+R` | Reset this segment to its original (pre-edit) text | +| `a` | Set the speaker on this segment only (prompt) | +| `S` | Rename the selected segment's speaker everywhere in this transcript (prompt) | | `Space` | Play / pause | | `s` | Stop | | `Ctrl+A` | Run LLM analysis on this recording | @@ -175,7 +181,7 @@ soundfile, and discarded on screen exit. ## Limitations (v1) -- No segment editing (view + copy only) -- No multi-select copy across segments (single segment via `y`, full - transcript via `Y`) +- No multi-select across transcript segments (single segment via `y`, full + transcript via `Y`, and speaker-rename applies to a whole speaker at once + rather than an arbitrary selection of segments) - Folder/tag pickers not yet wired into library filtering UI diff --git a/tui/api.py b/tui/api.py index 8bc6e7d..8a48aa0 100644 --- a/tui/api.py +++ b/tui/api.py @@ -101,6 +101,32 @@ async def recording(self, recording_id: str) -> dict: async def transcript(self, recording_id: str) -> dict: return await self._get(f"/api/recordings/{recording_id}/transcript") + async def edit_segment(self, recording_id: str, segment_index: int, text: str) -> dict: + return await self._patch_form( + f"/api/recordings/{recording_id}/transcript/segments/{segment_index}", + data={"text": text}, + ) + + async def reset_segment(self, recording_id: str, segment_index: int) -> dict: + return await self._post_form( + f"/api/recordings/{recording_id}/transcript/segments/{segment_index}/reset" + ) + + async def rename_speaker(self, recording_id: str, old_name: str, new_name: str) -> dict: + return await self._post_form( + f"/api/recordings/{recording_id}/transcript/rename-speaker", + data={"old_name": old_name, "new_name": new_name}, + ) + + async def assign_speaker(self, recording_id: str, segment_indices: list[int], speaker_name: str) -> dict: + return await self._post_form( + f"/api/recordings/{recording_id}/transcript/assign-speaker", + data={ + "segment_indices": ",".join(str(i) for i in segment_indices), + "speaker_name": speaker_name, + }, + ) + async def update_recording(self, recording_id: str, **fields: Any) -> dict: return await self._patch_form(f"/api/recordings/{recording_id}", data=fields) @@ -120,6 +146,17 @@ async def export( ) return r.content, filename + async def bulk_export_md(self, ids: list[str]) -> tuple[bytes, str | None]: + """Return (body, filename) for a combined markdown export of several recordings.""" + r = await self.client.post( + "/api/recordings/bulk-export/md", json={"ids": ids} + ) + r.raise_for_status() + filename = _filename_from_disposition( + r.headers.get("content-disposition") + ) + return r.content, filename + # --- folders / tags / search ------------------------------------ async def folders(self) -> list[dict]: diff --git a/tui/palette.py b/tui/palette.py index 3e5f657..423aa18 100644 --- a/tui/palette.py +++ b/tui/palette.py @@ -550,12 +550,18 @@ async def _activate(self, entry_key: str) -> None: ) if entry is None: return - _push_mru(self.app, entry.key) - # Ad-hoc mini-picker (e.g. analysis-type chooser) — defer to caller. + # Ad-hoc mini-pickers (analysis type, bulk actions, move/tag…) are + # rebuilt fresh on every open and often reuse the same entry keys + # across unrelated invocations (e.g. "bulk:delete" for whichever + # recordings happen to be selected this time) — MRU-boosting those + # would silently reorder the list and make a bare Enter trigger a + # different action than the one actually on top. Only the + # persistent command/recording palette benefits from MRU ranking. if self._ad_hoc_on_pick is not None: self.app.pop_screen() await self._ad_hoc_on_pick(self.app, entry) return + _push_mru(self.app, entry.key) # In delete mode, picking a recording confirms, then deletes. if self._mode == "delete" and entry.kind == "recording": rec_id = entry.key.split(":", 1)[1] @@ -756,6 +762,46 @@ async def _noop(app: "AmicoTUI") -> None: return None +def _folder_entries(folders: list[dict] | None, include_none: bool = True) -> list[Entry]: + entries: list[Entry] = [] + if include_none: + entries.append(Entry( + kind="folder", + key="folder:", + display="▢ (no folder)", + subtitle="remove from any folder", + search_text="no folder none", + on_select=_noop, + )) + entries += [ + Entry( + kind="folder", + key=f"folder:{f.get('id')}", + display=f"▣ {f.get('name', '?')}", + subtitle=f"folder · id {str(f.get('id'))[:8]}", + search_text=str(f.get("name", "")), + on_select=_noop, + ) + for f in (folders or []) if f.get("id") is not None + ] + return entries + + +def _tag_entries(tags: list[dict] | None, applied_ids: set[str] | None = None) -> list[Entry]: + applied_ids = applied_ids or set() + return [ + Entry( + kind="tag", + key=f"tag:{t.get('id')}", + display=f"{'●' if str(t.get('id')) in applied_ids else '○'} {t.get('name', '?')}", + subtitle="applied — enter removes" if str(t.get("id")) in applied_ids else "enter adds", + search_text=str(t.get("name", "")), + on_select=_noop, + ) + for t in (tags or []) if t.get("id") is not None + ] + + def _open_move_to_folder_picker(app: "AmicoTUI", rec_id: str) -> None: async def build_and_push() -> None: try: @@ -763,27 +809,7 @@ async def build_and_push() -> None: except Exception as e: app.notify(f"folders load failed: {e}", severity="error") return - entries = [ - Entry( - kind="folder", - key="folder:", - display="▢ (no folder)", - subtitle="remove from any folder", - search_text="no folder none", - on_select=_noop, - ) - ] - entries += [ - Entry( - kind="folder", - key=f"folder:{f.get('id')}", - display=f"▣ {f.get('name', '?')}", - subtitle=f"folder · id {str(f.get('id'))[:8]}", - search_text=str(f.get("name", "")), - on_select=_noop, - ) - for f in (folders or []) if f.get("id") is not None - ] + entries = _folder_entries(folders) async def on_pick(app: "AmicoTUI", entry: Entry) -> None: folder_id = entry.key.split(":", 1)[1] @@ -813,17 +839,7 @@ async def build_and_push() -> None: app.notify("no tags yet — create one with /tag new ") return applied_ids = {str(t.get("id")) for t in (rec.get("tags") or [])} - entries = [ - Entry( - kind="tag", - key=f"tag:{t.get('id')}", - display=f"{'●' if str(t.get('id')) in applied_ids else '○'} {t.get('name', '?')}", - subtitle="applied — enter removes" if str(t.get("id")) in applied_ids else "enter adds", - search_text=str(t.get("name", "")), - on_select=_noop, - ) - for t in all_tags if t.get("id") is not None - ] + entries = _tag_entries(all_tags, applied_ids) async def on_pick(app: "AmicoTUI", entry: Entry) -> None: tag_id = entry.key.split(":", 1)[1] @@ -845,6 +861,69 @@ async def on_pick(app: "AmicoTUI", entry: Entry) -> None: app.run_worker(build_and_push(), exclusive=False) +def open_bulk_move_picker(app: "AmicoTUI", rec_ids: list[str], on_done) -> None: + """Move several recordings to one folder. ``on_done()`` is called after.""" + async def build_and_push() -> None: + try: + folders = await app.api.folders() + except Exception as e: + app.notify(f"folders load failed: {e}", severity="error") + return + entries = _folder_entries(folders) + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + folder_id = entry.key.split(":", 1)[1] + app.push_busy() + errors = 0 + for rec_id in rec_ids: + try: + await app.api.update_recording(rec_id, folder_id=folder_id) + except Exception: + errors += 1 + app.pop_busy() + ok = len(rec_ids) - errors + app.notify(f"moved {ok}/{len(rec_ids)}" + (f" ({errors} failed)" if errors else "")) + on_done() + + app.push_screen(Palette(entries=entries, on_pick=on_pick, title=f"move {len(rec_ids)} to…")) + + app.run_worker(build_and_push(), exclusive=False) + + +def open_bulk_tag_picker(app: "AmicoTUI", rec_ids: list[str], on_done) -> None: + """Add one tag to several recordings. ``on_done()`` is called after.""" + async def build_and_push() -> None: + try: + all_tags = await app.api.tags() + except Exception as e: + app.notify(f"tags load failed: {e}", severity="error") + return + if not all_tags: + app.notify("no tags yet — create one with /tag new ") + return + entries = _tag_entries(all_tags) + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + tag_id = entry.key.split(":", 1)[1] + app.push_busy() + errors = 0 + for rec_id in rec_ids: + try: + await app.api.add_tag(rec_id, tag_id) + except Exception: + errors += 1 + app.pop_busy() + ok = len(rec_ids) - errors + app.notify(f"tagged {ok}/{len(rec_ids)}" + (f" ({errors} failed)" if errors else "")) + on_done() + + app.push_screen(Palette(entries=entries, on_pick=on_pick, title=f"tag {len(rec_ids)} recordings…")) + + app.run_worker(build_and_push(), exclusive=False) + + app.run_worker(build_and_push(), exclusive=False) + + def entries_from_folders(folders: list[dict] | None) -> list[Entry]: return [ Entry( diff --git a/tui/screens/library.py b/tui/screens/library.py index 0aa5c17..3b2e508 100644 --- a/tui/screens/library.py +++ b/tui/screens/library.py @@ -91,6 +91,8 @@ class LibraryPanel(Widget): Binding("t", "tag_row", "Tag"), Binding("y", "copy_name", "Copy name"), Binding("enter", "open", "Open"), + Binding("v", "toggle_select", "Select", show=False), + Binding("x", "bulk_menu", "Bulk actions"), ] DEFAULT_CSS = """ @@ -114,6 +116,8 @@ def __init__( self.folder_id = folder_id self.tag_id = tag_id self.on_loaded = on_loaded + self._items: list[dict] = [] + self.selected_ids: set[str] = set() def compose(self): with Vertical(): @@ -121,7 +125,7 @@ def compose(self): def on_mount(self) -> None: self.table = self.query_one(DataTable) - self.table.add_columns("FILE", "DATE", "DUR", "MODEL", "TAGS", "STATUS") + self.table.add_columns("", "FILE", "DATE", "DUR", "MODEL", "TAGS", "STATUS") self.refresh_library() def on_show(self) -> None: @@ -217,11 +221,100 @@ def action_copy_name(self) -> None: if rec_id is None or self.table is None: return row = self.table.get_row_at(self.table.cursor_row) - name_cell = row[0] + name_cell = row[1] name = name_cell.plain if isinstance(name_cell, Text) else str(name_cell) if copy_to_clipboard(name): self.app.notify(f"copied: {name}") + # --- multi-select / bulk actions --------------------------------- + + def action_toggle_select(self) -> None: + rec_id = self._selected_id() + if rec_id is None: + return + self.selected_ids.symmetric_difference_update({rec_id}) + self._render_rows() + if self.table and self.table.row_count: + self.table.action_cursor_down() + + def action_bulk_menu(self) -> None: + if not self.selected_ids: + self.app.notify("select rows first (Space), then x for bulk actions") + return + n = len(self.selected_ids) + from ..palette import Entry, Palette, _noop + + entries = [ + Entry(kind="bulk", key="bulk:delete", display=f"🗑 Delete {n} selected", + subtitle="", search_text="delete", on_select=_noop), + Entry(kind="bulk", key="bulk:export", display=f"⇩ Export {n} selected (combined markdown)", + subtitle="", search_text="export", on_select=_noop), + Entry(kind="bulk", key="bulk:move", display=f"▣ Move {n} selected to folder…", + subtitle="", search_text="move", on_select=_noop), + Entry(kind="bulk", key="bulk:tag", display=f"# Tag {n} selected…", + subtitle="", search_text="tag", on_select=_noop), + Entry(kind="bulk", key="bulk:clear", display="Clear selection", + subtitle="", search_text="clear", on_select=_noop), + ] + + async def on_pick(app: "AmicoTUI", entry: Entry) -> None: + action = entry.key.split(":", 1)[1] + if action == "delete": + self.run_worker(self._bulk_delete(), exclusive=False) + elif action == "export": + self.run_worker(self._bulk_export(), exclusive=False) + elif action == "move": + from ..palette import open_bulk_move_picker + open_bulk_move_picker(self.app, list(self.selected_ids), self._after_bulk) # type: ignore[arg-type] + elif action == "tag": + from ..palette import open_bulk_tag_picker + open_bulk_tag_picker(self.app, list(self.selected_ids), self._after_bulk) # type: ignore[arg-type] + elif action == "clear": + self.selected_ids.clear() + self._render_rows() + + self.app.push_screen(Palette(entries=entries, on_pick=on_pick, title=f"bulk actions ({n} selected)")) + + def _after_bulk(self) -> None: + self.selected_ids.clear() + self.refresh_library() + + async def _bulk_delete(self) -> None: + from ..widgets.confirm import ConfirmDialog + ids = list(self.selected_ids) + confirmed = await self.app.push_screen_wait( + ConfirmDialog(f"Delete {len(ids)} recordings…? This cannot be undone.") + ) + if not confirmed: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + errors = 0 + for rec_id in ids: + try: + await app.api.delete_recording(rec_id) + except Exception: + errors += 1 + app.pop_busy() + ok = len(ids) - errors + app.notify(f"deleted {ok}/{len(ids)}" + (f" ({errors} failed)" if errors else "")) + self._after_bulk() + + async def _bulk_export(self) -> None: + from pathlib import Path + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + body, filename = await app.api.bulk_export_md(list(self.selected_ids)) + out = Path.cwd() / (filename or "transcripts.md") + out.write_bytes(body) + app.notify(f"saved: {out}") + self._after_bulk() + except Exception as e: + app.notify(f"bulk export failed: {e}", severity="error") + finally: + app.pop_busy() + def action_open(self) -> None: rec_id = self._selected_id() if rec_id is None: @@ -250,19 +343,29 @@ async def _load(self) -> None: self.app.notify(f"library load failed: {e}", severity="error") return items = data.get("items", []) if isinstance(data, dict) else data + self._items = items + live_ids = {str(r["id"]) for r in items} + self.selected_ids &= live_ids + self._render_rows() + + def _render_rows(self) -> None: assert self.table is not None + cursor_row = self.table.cursor_row self.table.clear() self.row_keys.clear() total_dur = 0.0 - for r in items: - name = r.get("alias") or r.get("filename") or f"#{r.get('id')}" + for r in self._items: + rec_id = str(r["id"]) + name = r.get("alias") or r.get("filename") or f"#{rec_id}" model = r.get("model_size") or r.get("model") or "" dur = r.get("duration") or 0 try: total_dur += float(dur or 0) except (TypeError, ValueError): pass + checked = "◉" if rec_id in self.selected_ids else " " self.table.add_row( + Text(checked, style="#7c79f0" if rec_id in self.selected_ids else "#3a3d6a"), Text(name, style="#dde1ff"), Text(_fmt_date(r.get("created_at")), style="#6b6e9a"), Text(_fmt_duration(dur), style="#6b6e9a"), @@ -270,9 +373,11 @@ async def _load(self) -> None: _fmt_tags(r.get("tags")), _fmt_status(r.get("status", "")), ) - self.row_keys.append(str(r["id"])) + self.row_keys.append(rec_id) + if self.table.row_count: + self.table.move_cursor(row=min(cursor_row, self.table.row_count - 1)) if self.on_loaded: - self.on_loaded(len(items), total_dur) + self.on_loaded(len(self._items), total_dur, len(self.selected_ids)) def _selected_id(self) -> str | None: if not self.table or self.table.row_count == 0: @@ -286,7 +391,7 @@ def _selected_name(self) -> str | None: if not self.table or self.table.row_count == 0: return None row = self.table.get_row_at(self.table.cursor_row) - name_cell = row[0] + name_cell = row[1] return name_cell.plain if isinstance(name_cell, Text) else str(name_cell) @@ -338,7 +443,7 @@ def compose(self): id="library_panel", ) yield ContextHint( - "↑↓ navigate · ↵ open · R rename · m move · t tag · d delete · /search", + "↑↓ navigate · ↵ open · v select · x bulk · R rename · d delete · /search", id="ctxhint", ) yield CommandBar(id="cmdbar") @@ -347,13 +452,14 @@ def compose(self): def on_mount(self) -> None: self.query_one(LibraryPanel).query_one(DataTable).focus() - def _on_loaded(self, count: int, total_dur: float) -> None: + def _on_loaded(self, count: int, total_dur: float, selected: int = 0) -> None: h = int(total_dur // 3600) m = int((total_dur % 3600) // 60) + sel = f"{selected} selected · " if selected else "" try: self.query_one("#ctxhint", ContextHint).set_text( - f"{count} recordings · {h}h {m:02d}m total " - f"| ↑↓ navigate · ↵ open · R rename · m move · t tag · d delete" + f"{count} recordings · {h}h {m:02d}m total · {sel}" + f"↑↓ navigate · ↵ open · v select · x bulk · R rename · d delete" ) except Exception: pass diff --git a/tui/screens/transcript.py b/tui/screens/transcript.py index bac23e1..b688ee6 100644 --- a/tui/screens/transcript.py +++ b/tui/screens/transcript.py @@ -32,6 +32,10 @@ class TranscriptScreen(Screen): Binding("s", "stop_play", "Stop"), Binding("ctrl+a", "analyze", "Analyze"), Binding("slash", "focus_search", "Find"), + Binding("e", "edit_segment", "Edit"), + Binding("ctrl+r", "reset_segment", "Reset seg"), + Binding("a", "assign_speaker", "Assign speaker"), + Binding("S", "rename_speaker", "Rename speaker"), ] DEFAULT_CSS = """ @@ -101,7 +105,7 @@ def compose(self): yield WaveformView(id="wave") yield SegmentList(id="segments") yield ContextHint( - "Space play · y copy seg · Y copy all · / find · /export json|srt|txt|md · ^A analyze", + "Space play · y copy · / find · e edit · a speaker · S rename speaker · ^A analyze", id="ctxhint", ) yield CommandBar(id="cmdbar") @@ -288,6 +292,106 @@ def action_analyze(self) -> None: from ..palette import _open_analysis_type_picker _open_analysis_type_picker(self.app, self.recording_id) + def action_edit_segment(self) -> None: + seg_list = self.query_one(SegmentList) + idx = seg_list.highlighted + seg = seg_list.selected_segment() + if idx is None or seg is None: + return + self.run_worker(self._edit_segment(idx, seg.get("text") or ""), exclusive=False) + + async def _edit_segment(self, index: int, current_text: str) -> None: + from ..widgets.prompt import PromptDialog + new_text = await self.app.push_screen_wait( + PromptDialog("Edit segment text:", initial=current_text) + ) + if not new_text or new_text == current_text: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + await app.api.edit_segment(self.recording_id, index, new_text) + self.query_one(SegmentList).update_segment_text(index, new_text) + self.query_one(StatusBar).flash("segment updated") + except Exception as e: + app.notify(f"edit failed: {e}", severity="error") + finally: + app.pop_busy() + + def action_reset_segment(self) -> None: + seg_list = self.query_one(SegmentList) + idx = seg_list.highlighted + if idx is None: + return + self.run_worker(self._reset_segment(idx), exclusive=False) + + async def _reset_segment(self, index: int) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + result = await app.api.reset_segment(self.recording_id, index) + text = result.get("text", "") + self.query_one(SegmentList).update_segment_text(index, text) + self.query_one(StatusBar).flash("segment reset") + except Exception as e: + app.notify(f"reset failed: {e}", severity="error") + finally: + app.pop_busy() + + def action_assign_speaker(self) -> None: + seg_list = self.query_one(SegmentList) + idx = seg_list.highlighted + seg = seg_list.selected_segment() + if idx is None or seg is None: + return + current = seg.get("speaker") or seg.get("speaker_label") or "" + self.run_worker(self._assign_speaker(idx, current), exclusive=False) + + async def _assign_speaker(self, index: int, current: str) -> None: + from ..widgets.prompt import PromptDialog + new_speaker = await self.app.push_screen_wait( + PromptDialog("Speaker for this segment:", initial=current) + ) + if not new_speaker or new_speaker == current: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + await app.api.assign_speaker(self.recording_id, [index], new_speaker) + self.query_one(SegmentList).update_segment_speaker(index, new_speaker) + self.query_one(StatusBar).flash(f"speaker set to {new_speaker}") + except Exception as e: + app.notify(f"assign failed: {e}", severity="error") + finally: + app.pop_busy() + + def action_rename_speaker(self) -> None: + seg_list = self.query_one(SegmentList) + seg = seg_list.selected_segment() + current = (seg.get("speaker") or seg.get("speaker_label") or "") if seg else "" + if not current: + self.app.notify("select a segment with a speaker first") + return + self.run_worker(self._rename_speaker(current), exclusive=False) + + async def _rename_speaker(self, old_name: str) -> None: + from ..widgets.prompt import PromptDialog + new_name = await self.app.push_screen_wait( + PromptDialog(f"Rename speaker “{old_name}” to:", initial=old_name) + ) + if not new_name or new_name == old_name: + return + app: "AmicoTUI" = self.app # type: ignore[assignment] + app.push_busy() + try: + await app.api.rename_speaker(self.recording_id, old_name, new_name) + self.query_one(SegmentList).rename_speaker_everywhere(old_name, new_name) + self.query_one(StatusBar).flash(f"speaker renamed to {new_name}") + except Exception as e: + app.notify(f"rename failed: {e}", severity="error") + finally: + app.pop_busy() + def _play_from(self, offset_s: float) -> None: if not self._tmp_audio or not self._tmp_audio.exists(): self.query_one(StatusBar).flash("audio not loaded yet") diff --git a/tui/widgets/segment_list.py b/tui/widgets/segment_list.py index f8907c6..bf8bf3a 100644 --- a/tui/widgets/segment_list.py +++ b/tui/widgets/segment_list.py @@ -88,6 +88,26 @@ def selected_segment(self) -> dict | None: return None return self.segments[idx] + def update_segment_text(self, index: int, text: str) -> None: + """Patch one segment's text in place (after an edit/reset) without + losing scroll position / highlight the way a full reload would.""" + if not (0 <= index < len(self.segments)): + return + self.segments[index]["text"] = text + self.replace_option_prompt_at_index(index, self._format_row(self.segments[index])) + + def update_segment_speaker(self, index: int, speaker: str) -> None: + if not (0 <= index < len(self.segments)): + return + self.segments[index]["speaker"] = speaker + self.replace_option_prompt_at_index(index, self._format_row(self.segments[index])) + + def rename_speaker_everywhere(self, old_name: str, new_name: str) -> None: + for i, seg in enumerate(self.segments): + if (seg.get("speaker") or seg.get("speaker_label") or "") == old_name: + seg["speaker"] = new_name + self.replace_option_prompt_at_index(i, self._format_row(seg)) + def find_first(self, query: str, start_from: int = 0) -> int | None: """Case-insensitive substring search over segment text, wrapping around from ``start_from``. Returns the matching index, or None."""