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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions backend/api/routes/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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 {}
Expand Down
18 changes: 17 additions & 1 deletion backend/api/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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", ""),
Expand All @@ -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"],
}


Expand All @@ -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.

Expand All @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
54 changes: 53 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3162,6 +3162,35 @@ <h3 class="text-lg font-bold text-slate-800">Keyboard Shortcuts</h3>
}
}

// 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
// =========================================================================
Expand Down Expand Up @@ -6272,11 +6301,18 @@ <h3 class="text-lg font-bold text-slate-800">Keyboard Shortcuts</h3>
// 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);
})();
}

Expand Down Expand Up @@ -6710,6 +6746,22 @@ <h4 class="font-semibold text-slate-800 mb-1">Running in Docker?</h4>
Set host.docker.internal
</button>
</div>
<!-- Terminal UI tip -->
<div class="rounded-lg bg-slate-50 border border-slate-200 p-4">
<h4 class="font-semibold text-slate-800 mb-1">Prefer the terminal?</h4>
<p class="text-xs text-slate-500 mb-2">AmicoScript also ships a keyboard-driven terminal
interface (TUI) — same backend, no browser needed.</p>
<code id="tui-command"
class="block bg-slate-100 text-slate-700 px-2 py-1.5 rounded font-mono text-xs mb-2 break-all"></code>
<div class="flex items-center gap-2">
<button id="tui-copy-btn" type="button"
class="text-xs px-3 py-1.5 rounded-lg bg-brand text-white hover:bg-brand-hover transition">
Copy command
</button>
<a href="https://github.com/sim186/AmicoScript/blob/main/tui/README.md" target="_blank"
rel="noopener" class="text-xs text-brand hover:underline">TUI docs ↗</a>
</div>
</div>
<!-- Links -->
<div class="space-y-2">
<a href="https://github.com/sim186/AmicoScript#readme" target="_blank" rel="noopener"
Expand Down
Binary file added images/tui_welcome.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions tests/test_tui_fuzzy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Tests for the TUI palette fuzzy matcher."""
from __future__ import annotations

from tui.fuzzy import rank, score_match


def test_exact_prefix_outranks_substring():
assert score_match("lib", "library") > score_match("lib", "available libs")


def test_no_match_returns_none():
assert score_match("xyz", "library") is None


def test_consecutive_chars_boosted():
"""Consecutive subsequence beats sparse non-boundary subsequence."""
consecutive = score_match("abc", "abcdef")
spread = score_match("abc", "axxxbxxxcxxx")
assert consecutive is not None and spread is not None
assert consecutive > spread


def test_word_boundary_boost():
"""Acronym-style matches across word boundaries rank well."""
boundary = score_match("abc", "a_b_c_d")
nonboundary = score_match("abc", "azzbzzczz")
assert boundary is not None and nonboundary is not None
assert boundary > nonboundary


def test_empty_query_preserves_order():
items = ["one", "two", "three"]
out = rank("", items)
assert [it for _s, it in out] == items


def test_rank_sorts_desc():
items = ["report.md", "rapid.md", "readme.md"]
out = rank("rea", items)
assert out[0][1] == "readme.md"
# Non-subsequence matches dropped.
out2 = rank("zzz", items)
assert out2 == []
58 changes: 58 additions & 0 deletions tests/test_tui_palette.py
Original file line number Diff line number Diff line change
@@ -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) == []
16 changes: 16 additions & 0 deletions tui.bat
Original file line number Diff line number Diff line change
@@ -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 %*
14 changes: 14 additions & 0 deletions tui.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
Loading
Loading