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/backend/api/routes/releases.py b/backend/api/routes/releases.py index de9b25a..d836bee 100644 --- a/backend/api/routes/releases.py +++ b/backend/api/routes/releases.py @@ -3,6 +3,9 @@ from pathlib import Path from fastapi import APIRouter, 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 4a1a37d..b3b6c55 100644 --- a/backend/api/routes/settings.py +++ b/backend/api/routes/settings.py @@ -14,8 +14,10 @@ from settings import ( _get_meeting_capture_enabled, _get_transcription_defaults, + _get_whisper_settings, _load_settings, _save_settings, + _save_whisper_settings, _set_meeting_capture_enabled, _set_transcription_defaults, ) @@ -66,6 +68,7 @@ def get_settings() -> dict: import state settings = _load_settings() defaults = _get_transcription_defaults() + ws = _get_whisper_settings() return { "hf_token": settings.get("hf_token", ""), "exit_token": getattr(state, "exit_token", ""), @@ -75,6 +78,9 @@ def get_settings() -> dict: "default_model": defaults["default_model"], "default_language": defaults["default_language"], "default_diarize": defaults["default_diarize"], + "whisper_model": ws["whisper_model"], + "whisper_device": ws["whisper_device"], + "whisper_compute": ws["whisper_compute"], } @@ -84,6 +90,9 @@ async def save_settings( model: str | None = Form(None), language: str | None = Form(None), diarize: str | None = Form(None), + whisper_model: str | None = Form(None), + whisper_device: str | None = Form(None), + whisper_compute: str | None = Form(None), ) -> dict: """Persist HF token and/or transcription defaults. @@ -100,7 +109,14 @@ async def save_settings( language=language, diarize=_to_bool(diarize) if diarize is not None else None, ) - return {"ok": True, **_get_transcription_defaults()} + if whisper_model: + ws = _get_whisper_settings() + _save_whisper_settings( + whisper_model, + whisper_device or ws["whisper_device"], + whisper_compute or ws["whisper_compute"], + ) + return {"ok": True, **_get_transcription_defaults(), **_get_whisper_settings()} @router.post("/api/settings/meeting-capture") diff --git a/backend/settings.py b/backend/settings.py index 10aab64..333a199 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -119,3 +119,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/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 + +
+

Prefer the terminal?

+

AmicoScript also ships a keyboard-driven terminal + interface (TUI) — same backend, no browser needed.

+ +
+ + TUI docs ↗ +
+
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..4f37bab --- /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": [ + {"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 == {"tiny", "base", "small"} + + +def test_entries_from_models_handles_bare_list(): + out = entries_from_models(["tiny", "base"]) + assert {e.display for e in out} == {"tiny", "base"} + + +def test_entries_from_models_empty(): + assert entries_from_models({}) == [] + assert entries_from_models(None) == [] diff --git a/tui.bat b/tui.bat new file mode 100644 index 0000000..ffdd054 --- /dev/null +++ b/tui.bat @@ -0,0 +1,16 @@ +@echo off +rem Convenience launcher: ensures TUI deps are installed, then runs the TUI. +rem Usage: tui.bat [--api-url http://host:port] [--no-server] [--debug] +setlocal +cd /d "%~dp0" + +if "%PYTHON%"=="" set PYTHON=python + +"%PYTHON%" -c "import textual, httpx" >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/README.md b/tui/README.md new file mode 100644 index 0000000..d70a914 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,187 @@ +# AmicoScript TUI + +Terminal interface for AmicoScript. Wraps the FastAPI backend over HTTP/SSE. + +## Screenshot + +![AmicoScript TUI welcome screen](../images/tui_welcome.png) + +## 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 | Whisper models | set as default transcription model | +| `/llm ` | llm_model | LLM models | set as default LLM 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 | +| `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 | + +### Transcript +| Key | Action | +|-----|--------| +| `↑↓` / `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 | +| `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 | +| `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 | +| `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 | +| `/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 `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) | +| `/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 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/__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..29a2e06 --- /dev/null +++ b/tui/__main__.py @@ -0,0 +1,35 @@ +"""Entry point: `python -m tui`.""" +from __future__ import annotations + +import sys + + +def main() -> int: + 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) + + 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..8a48aa0 --- /dev/null +++ b/tui/api.py @@ -0,0 +1,359 @@ +"""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 + +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 _post_form(self, path: str, data: Any = None) -> Any: + r = await self.client.post(path, data=data) + r.raise_for_status() + return r.json() if r.content else {} + + async def _patch_form(self, path: str, data: Any = None) -> Any: + r = await self.client.patch(path, data=data) + 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 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") + + # --- 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 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) + + 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 + + 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]: + 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_form( + "/api/folders", + data=_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_form(f"/api/folders/{folder_id}", data=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_form( + "/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}" + ) + + 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) -> 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") + + 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 = {"source_url": url, **_drop_none(options)} + return await self._post_form("/api/transcribe/url", data=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 + + 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() + 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_form( + f"/api/recordings/{recording_id}/analyses", + data={"analysis_type": analysis_type, **_drop_none(opts)}, + ) + + async def llm_settings(self) -> dict: + raw = await self._get("/api/llm/settings") + return { + "base_url": raw.get("llm_base_url", ""), + "model_name": raw.get("llm_model_name", ""), + "api_key": raw.get("llm_api_key", ""), + } + + async def save_llm_settings( + self, + base_url: str | None = None, + model_name: str | None = None, + api_key: str | None = None, + ) -> dict: + return await self._post_form( + "/api/llm/settings", + data=_drop_none({ + "llm_base_url": base_url, + "llm_model_name": model_name, + "llm_api_key": api_key, + }), + ) + + 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, + whisper_model: str | None = None, + whisper_device: str | None = None, + whisper_compute: str | None = None, + ) -> dict: + return await self._post_form( + "/api/settings", + data=_drop_none({ + "hf_token": hf_token, + "whisper_model": whisper_model, + "whisper_device": whisper_device, + "whisper_compute": whisper_compute, + }), + ) + + # --- meeting watcher ---------------------------------------------- + + async def watcher_status(self) -> dict: + return await self._get("/api/watcher/status") + + +# --- 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..0ababbf --- /dev/null +++ b/tui/app.py @@ -0,0 +1,273 @@ +"""Main Textual App for AmicoScript TUI. + +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 + +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 .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 = 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 = [ + Binding("ctrl+c", "quit", "Quit", 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), + ] + + 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) + 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() + + 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) + + 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 + + while True: + try: + data = await self.api.jobs() + rows = data.get("jobs", []) if isinstance(data, dict) else [] + 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: + if self.leader.handle_key(event): + event.stop() + event.prevent_default() + + # --- actions -------------------------------------------------------- + + def action_palette(self, seed: str = "") -> None: + 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 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('"', '\\"') + '"' + 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..298c1e0 --- /dev/null +++ b/tui/commands.py @@ -0,0 +1,416 @@ +"""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 + 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 -------------------------------------------------------- + + +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): + 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") +async def _transcribe(app, args): + if not args: + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/transcribe ") + return + + 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 + + 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 ") +async def _transcribe_url(app, args): + if not args: + app.notify("usage: /transcribe-url ") + return + 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 + app.push_screen(JobDetailScreen(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) + from .screens.search import SearchScreen + app.push_screen(SearchScreen(q)) + + +@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: + from .palette import Palette, seed_palette + pal = Palette() + app.push_screen(pal) + pal.call_after_refresh(seed_palette, pal, "/delete ") + return + 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() + + +@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: + app.notify("usage: /folder new ") + return + name = " ".join(args[1:]) + await app.api.create_folder(name) + app.notify(f"folder created: {name}") + return + 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 (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) + pal.call_after_refresh(seed_palette, pal, "/tag ") + + + + +@command("logs", "show server log buffer") +async def _logs(app, args): + from .screens.logs import LogsScreen + app.push_screen(LogsScreen()) + + +@command("settings", "open settings screen") +async def _settings(app, args): + from .screens.settings import SettingsScreen + 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 + app.push_screen(LibraryScreen()) + + +@command("jobs", "open the active-jobs list") +async def _jobs(app, args): + from .screens.jobs_list import JobsListScreen + app.push_screen(JobsListScreen()) + + +@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 a Whisper transcription 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", "pick an LLM model") +async def _llm(app, args): + 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") +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..dd55fe4 --- /dev/null +++ b/tui/leader.py @@ -0,0 +1,99 @@ +"""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: + try: + for bar in self.app.status_bars(): + 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..423aa18 --- /dev/null +++ b/tui/palette.py @@ -0,0 +1,1028 @@ +"""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 pathlib import Path +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", "llm", "transcribe", "delete"} +# 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", + "llm": "llm_model", + "transcribe": "transcribe", + "delete": "delete", +} + + +@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: rgba(12,14,26,0.85); + } + #box { + width: 70%; + max-width: 90; + height: auto; + padding: 0; + 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: #12152a; + border: none; + color: #dde1ff; + } + #suggestions > .option-list--option-highlighted { + background: #2d2a7a; + color: #dde1ff; + } + CommandInput { + border: none; + background: #1a1d35; + color: #dde1ff; + height: 1; + padding: 0 2; + border-top: solid #4a47c0; + } + #hint { + height: 1; + color: #6b6e9a; + background: #12152a; + padding: 0 2; + border-top: solid #2a2860; + } + """ + + 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] = [] + self._llm_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 + # 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"): + yield Static("command palette", id="header") + yield OptionList(id="suggestions") + yield CommandInput(placeholder="/") + 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 = [] + 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}", + 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.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 + app: "AmicoTUI" = self.app # type: ignore[assignment] + try: + tags = await app.api.tags() + except Exception: + 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]: + """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: + 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", + "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", + "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) + + 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, query = self._parse(raw) + mode_changed = mode != self._mode + if mode_changed: + self._mode = 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: + 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" or mode == "delete": + return self._recordings + if mode == "folder": + return self._folders + if mode == "tag": + 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 effective_query: + for e in pool: + s = score_match(effective_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 #7c79f0]{e.display:<14}[/] [#6b6e9a]{e.subtitle}[/]", + 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: 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": + 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: + 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 + # Final 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("/"): + mode, _ = self._parse(text) + if mode == "transcribe": + return + 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 + # 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] + 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 + app.push_busy() + 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") + finally: + app.pop_busy() + 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 + # 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) + + 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 + + self._llm_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 + + +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 -------------------------------------------------- + + +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_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"LLM model set to {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 _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: + 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] + 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 = _tag_entries(all_tags, applied_ids) + + 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 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( + 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 []) + 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): + mid = it + name = it + elif isinstance(it, dict): + 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=mid, + on_select=_set_llm_model(mid), + )) + return entries + + +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/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 new file mode 100644 index 0000000..955da50 --- /dev/null +++ b/tui/screens/import_.py @@ -0,0 +1,307 @@ +"""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. A ``/`` search +box lets you recursively fuzzy-find files under the current directory. +""" +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, OptionList, Static +from textual.widgets.option_list import Option + +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"), + Binding("slash", "focus_search", "Search"), + 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"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/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; + } + #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: + 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" + 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 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 · / search · 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": + 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 + + 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: + 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 + # 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()) + + 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() + + 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 + 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))}") + + 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/job_detail.py b/tui/screens/job_detail.py new file mode 100644 index 0000000..2c5d518 --- /dev/null +++ b/tui/screens/job_detail.py @@ -0,0 +1,115 @@ +"""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 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 + +if TYPE_CHECKING: + 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"), + Binding("q", "pop", "Back"), + 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; } + Log { height: 1fr; border: tall $panel; } + """ + + 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 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 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) + + 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"]) + # 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"])) + 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/jobs_list.py b/tui/screens/jobs_list.py new file mode 100644 index 0000000..de623b9 --- /dev/null +++ b/tui/screens/jobs_list.py @@ -0,0 +1,173 @@ +"""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"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "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 new file mode 100644 index 0000000..3b2e508 --- /dev/null +++ b/tui/screens/library.py @@ -0,0 +1,470 @@ +"""Library panel: list of recordings with keyboard navigation.""" +from __future__ import annotations + +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 + +from ..clipboard import copy_to_clipboard +from ..widgets.chrome import CommandBar, ContextHint, TitleBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +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 "--" + s = int(seconds) + h, rem = divmod(s, 3600) + m, _ = divmod(rem, 60) + return f"{h:d}h {m:02d}m" + + +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") + except (ValueError, OSError): + return "" + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime( + "%Y-%m-%d" + ) + except ValueError: + 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.""" + + 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("R", "rename_row", "Rename"), + Binding("m", "move_row", "Move"), + 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 = """ + LibraryPanel { layout: vertical; height: 1fr; } + DataTable { height: 1fr; background: #0c0e1a; } + """ + + def __init__( + self, + status_filter: str | None = None, + folder_id: str | None = None, + tag_id: str | None = None, + on_loaded=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 + self.on_loaded = on_loaded + self._items: list[dict] = [] + self.selected_ids: set[str] = set() + + 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", "DATE", "DUR", "MODEL", "TAGS", "STATUS") + self.refresh_library() + + def on_show(self) -> None: + 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.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] + 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_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: + return + row = self.table.get_row_at(self.table.cursor_row) + 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: + 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 + 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 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"), + Text(model, style="#7c79f0"), + _fmt_tags(r.get("tags")), + _fmt_status(r.get("status", "")), + ) + 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(self._items), total_dur, len(self.selected_ids)) + + 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 + + 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[1] + return name_cell.plain if isinstance(name_cell, Text) else str(name_cell) + + +class LibraryScreen(Screen): + """Full-screen library view.""" + + BINDINGS = [ + Binding("escape", "pop", "Back"), + ] + + leader_chords = { + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "i": ("Import", "/import"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "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 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 ContextHint( + "↑↓ navigate · ↵ open · v select · x bulk · R rename · d delete · /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 _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 · {sel}" + f"↑↓ navigate · ↵ open · v select · x bulk · R rename · d delete" + ) + except Exception: + pass + + def action_pop(self) -> None: + self.app.pop_screen() + + diff --git a/tui/screens/logs.py b/tui/screens/logs.py new file mode 100644 index 0000000..e2b0602 --- /dev/null +++ b/tui/screens/logs.py @@ -0,0 +1,98 @@ +"""Live server log tail screen.""" +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 Log + +from ..widgets.chrome import CommandBar, ContextHint, TitleBar +from ..widgets.status_bar import StatusBar + +if TYPE_CHECKING: + from ..app import AmicoTUI + + +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"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/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: + # 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: + 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..a4e5ccc --- /dev/null +++ b/tui/screens/search.py @@ -0,0 +1,136 @@ +"""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"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/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 new file mode 100644 index 0000000..05ccece --- /dev/null +++ b/tui/screens/settings.py @@ -0,0 +1,178 @@ +"""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 Horizontal, Vertical, VerticalScroll +from textual.screen import Screen +from textual.widget import Widget +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; } + 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 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: + app: "AmicoTUI" = self.app # type: ignore[assignment] + 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: + 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: + 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 + try: + 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, + 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): + BINDINGS = [Binding("escape", "pop", "Back")] + + leader_chords = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "i": ("Import", "/import"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), + } + + DEFAULT_CSS = """ + SettingsScreen { layout: vertical; } + SettingsPanel { height: 1fr; } + """ + + def __init__(self) -> None: + super().__init__() + self.title = "Settings" + + def compose(self): + yield TitleBar(id="titlebar") + with Vertical(): + yield SettingsPanel(id="settings_panel") + 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 new file mode 100644 index 0000000..b688ee6 --- /dev/null +++ b/tui/screens/transcript.py @@ -0,0 +1,413 @@ +"""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 Horizontal, Vertical +from textual.screen import Screen +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, parse_timestamp +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"), + 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 = """ + TranscriptScreen { layout: vertical; } + #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; + } + #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 = { + "l": ("Library", "/library"), + "j": ("Jobs", "/jobs"), + "s": ("Settings", "/settings"), + "h": ("Welcome", "/welcome"), + "question_mark": ("Help", "/help"), + "q": ("Quit", "/quit"), + } + + 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 + self.title = "Transcript" + + 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 · / find · e edit · a speaker · S rename speaker · ^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) + + async def _load(self) -> None: + app: "AmicoTUI" = self.app # type: ignore[assignment] + 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: + 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) + model = rec.get("model_size") or rec.get("model") or "" + + 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) + 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[/]" + ) + + 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 + + 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)) + + 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))) + + 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: + 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) + + 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") + 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..a9d2287 --- /dev/null +++ b/tui/screens/welcome.py @@ -0,0 +1,102 @@ +"""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, Vertical +from textual.screen import Screen +from textual.widgets import Static + + +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"), + "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; + } + WelcomeScreen > Container { + height: 1fr; + align: center middle; + } + #welcome-panel { + width: auto; + height: auto; + border: round #4a47c0; + padding: 1 5; + align: center middle; + } + #app-title { + color: #dde1ff; + width: 100%; + text-align: center; + height: auto; + } + #tagline { + color: #6b6e9a; + width: 100%; + text-align: center; + height: auto; + padding: 0 0 1 0; + } + #quick-actions { + color: #dde1ff; + width: auto; + height: auto; + padding: 1 0; + } + #keyref { + color: #3a3d6a; + width: 100%; + text-align: center; + height: auto; + padding: 1 0 0 0; + } + """ + + def compose(self): + with Container(): + 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/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/chrome.py b/tui/widgets/chrome.py new file mode 100644 index 0000000..0ffe370 --- /dev/null +++ b/tui/widgets/chrome.py @@ -0,0 +1,131 @@ +"""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(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] + api = app.api.base_url + except Exception: + api = "" + screen_name = getattr(self.screen, "title", None) or "AmicoScript" + self.query_one("#title-left", Static).update( + f"AmicoScript — {api} · [b]{screen_name}[/b]" + ) + + +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/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/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/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/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) diff --git a/tui/widgets/segment_list.py b/tui/widgets/segment_list.py new file mode 100644 index 0000000..bf8bf3a --- /dev/null +++ b/tui/widgets/segment_list.py @@ -0,0 +1,169 @@ +"""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 + + +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) + 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 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), + 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; + 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))) + + 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"[#6b6e9a]{ts}[/]" + if speaker: + color = self.speaker_color(speaker) + prefix += f" [{color} b]{speaker}[/]" + return f"{prefix} [#dde1ff]{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 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.""" + 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 + + 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..069c086 --- /dev/null +++ b/tui/widgets/status_bar.py @@ -0,0 +1,134 @@ +"""Bottom status bar: connection state, hints, transient messages.""" +from __future__ import annotations + +from textual.reactive import reactive +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. + + 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 #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") + message: reactive[str] = reactive("") + 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") + yield Static("[dim]Space ?[/] Help [#ef4444]Space q[/] Quit", id="status-right") + + 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() + + 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 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) + except Exception: + return + if self.leader_hint: + left.update(f"LEADER · {self.leader_hint}") + 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[/]" + if self.message: + text += f" · {self.message}" + text += f" · {self.hint}" + left.update(text) + + def set_connection(self, state: str, ok: bool = True) -> None: + self.connection = state + self.set_class(not ok, "-error") + + 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