aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py161
-rw-r--r--app/ui/taskview.py23
-rw-r--r--app/ui/tui.py3
3 files changed, 139 insertions, 48 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 285a1cf..29edf9b 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -25,6 +25,7 @@ import io
import json
import re
import shutil
+import sys
import urllib.parse
from datetime import datetime
from pathlib import Path
@@ -366,59 +367,137 @@ class _Hub:
# -- servers --------------------------------------------------------
def screen_server(self):
+ """One flat menu of every installed backend's servers.
+
+ A status table above the menu shows each server's live state —
+ "running" (green) or "stopped" (red) — and selecting an entry
+ toggles it directly (starts a stopped server, stops a running one)
+ without an extra action menu. The state lives in the table, not on
+ the entries, because the menu's selection bar would cover inline
+ colors. A backend with a single server is labelled by its name; a
+ multi-server backend (qwen: CustomVoice + Base) gets one entry per
+ server, suffixed with the server name.
+ """
statuses = detect_all()
candidates = [st for st in statuses if st.installed]
if not candidates:
tui.flash(self.stdscr, "No backend is installed yet — use "
"'Configure backends' first.")
return tui.Wizard.BACK
- options = [(st.label, st.key) for st in candidates]
- key = tui.menu(self.stdscr, "Start / Stop a server", options,
- back_value=tui.Wizard.BACK,
- table_title="Backend status",
- table_rows=_status_rows(statuses),
- notice_lines=_notice_lines())
- if key is tui.Wizard.BACK:
- return tui.Wizard.BACK
- status = next((s for s in statuses if s.key == key), None)
- if status is None:
- return tui.Wizard.BACK
- specs = status.servers
- if not specs:
- tui.flash(self.stdscr, f"{status.label} has no server "
- "configured. Run 'Configure backends' first.")
- return tui.Wizard.BACK
- if len(specs) == 1:
- return functools.partial(self._server_action, specs[0])
- return functools.partial(self._server_spec, status, specs)
-
- def _server_spec(self, status, specs):
- options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})",
- s.name) for s in specs]
- name = tui.menu(self.stdscr, f"{status.label} server", options,
- back_value=tui.Wizard.BACK)
- if name is tui.Wizard.BACK:
+ options = []
+ rows = []
+ for st in candidates:
+ specs = st.servers
+ for spec in specs:
+ running = common.server_running(spec.url)
+ label = st.label if len(specs) == 1 \
+ else f"{st.label} — {spec.name}"
+ options.append((label, spec))
+ rows.append((label,
+ "running" if running else "stopped",
+ "ok" if running else "err", "body"))
+ if not options:
+ tui.flash(self.stdscr, "No backend server is configured yet — "
+ "use 'Configure backends' first.")
return tui.Wizard.BACK
- spec = next((s for s in specs if s.name == name), None)
- if spec is None:
+ spec = tui.menu(self.stdscr, "Start / Stop a server", options,
+ back_value=tui.Wizard.BACK,
+ help_lines=["Select a server to start or stop it."],
+ table_title="Server status",
+ table_rows=rows,
+ notice_lines=_notice_lines())
+ if spec is tui.Wizard.BACK:
return tui.Wizard.BACK
- return functools.partial(self._server_action, spec)
+ return functools.partial(self._server_toggle, spec)
- def _server_action(self, spec):
+ def _server_toggle(self, spec):
+ """Start SPEC's server when stopped, stop it when running.
+
+ Runs inside the task view (no console drop); the server module's
+ plain-console output is tee'd to a log file under ``app/logs`` so
+ nothing is lost, and on failure a flash points the user at that
+ file. Returns BACK so the stack lands back on the server list,
+ which re-reads each server's live state.
+ """
running = common.server_running(spec.url)
- action = tui.menu(
- self.stdscr,
- f"{spec.name} ({'running' if running else 'stopped'})",
- [("Start", "start"), ("Stop", "stop")],
- back_value=tui.Wizard.BACK)
- if action is tui.Wizard.BACK:
- return tui.Wizard.BACK
- with tui.suspend(self.stdscr):
+ action = "stop" if running else "start"
+ step, log_path = _server_action_step(spec, action)
+ taskview.run_steps(self.stdscr, f"{action.capitalize()} "
+ f"{spec.name} server", [step],
+ wait_on_finish=False)
+ # Re-check the server instead of trusting the step's exit code
+ # (cancel and failure both come back non-zero): did the toggle take?
+ now_running = common.server_running(spec.url)
+ if action == "start" and not now_running:
+ tui.flash(self.stdscr, f"Could not start the {spec.name} "
+ f"server. See the log: {log_path}", "err")
+ elif action == "stop" and now_running:
+ tui.flash(self.stdscr, f"Could not stop the {spec.name} "
+ f"server. See the log: {log_path}", "err")
+ return tui.Wizard.BACK
+
+
+class _TeeWriter:
+ """A file-like that mirrors writes to a log file and an inner stream.
+
+ Used to capture the server module's plain-console output (the task view
+ already redirects stdout to its line-writer) into a persistent log file
+ under ``servers.LOG_DIR`` without losing the on-screen log tail.
+ """
+
+ def __init__(self, logf, inner):
+ self._logf = logf
+ self._inner = inner
+
+ def write(self, text):
+ if not text:
+ return 0
+ try:
+ self._logf.write(text)
+ except OSError:
+ pass
+ try:
+ self._inner.write(text)
+ except OSError:
+ pass
+ return len(text)
+
+ def flush(self):
+ try:
+ self._logf.flush()
+ except OSError:
+ pass
+ try:
+ self._inner.flush()
+ except OSError:
+ pass
+
+
+def _server_action_step(spec, action: str):
+ """Build a task step that starts/stops SPEC's server, logged to a file.
+
+ ACTION is "start" or "stop". The step runs inside the task view (no
+ console drop): the server module's output is tee'd to
+ ``<servers.LOG_DIR>/<name>-<action>.log`` and to the view's log tail.
+ Returns ``(TaskStep, log_path)`` so the caller can point the user at the
+ file on failure.
+ """
+ log_path = servers.LOG_DIR / f"{spec.name}-{action}.log"
+ title = (f"Start {spec.name} server" if action == "start"
+ else f"Stop {spec.name} server")
+
+ def work(emit, cancel):
+ servers.LOG_DIR.mkdir(parents=True, exist_ok=True)
+ inner = sys.stdout # the task view's line-writer, when run in TUI
+ with log_path.open("w", encoding="utf-8") as logf, \
+ contextlib.redirect_stdout(_TeeWriter(logf, inner)):
if action == "start":
- servers.start(spec)
+ ok = servers.start(spec, cancel=cancel)
else:
- servers.stop(spec.name)
- return tui.Wizard.BACK
+ ok = servers.stop(spec.name)
+ return 0 if ok else 1
+
+ return taskview.TaskStep(title, work), log_path
def _installable(info, by_key: dict) -> bool:
diff --git a/app/ui/taskview.py b/app/ui/taskview.py
index 7f8134b..ea72a86 100644
--- a/app/ui/taskview.py
+++ b/app/ui/taskview.py
@@ -143,13 +143,17 @@ def _lane_step_mark(current: Optional[int],
return "[ ]", "dim"
-def run_steps(scr, title: str, steps: List[TaskStep]) -> int:
+def run_steps(scr, title: str, steps: List[TaskStep],
+ wait_on_finish: bool = True) -> int:
"""Run STEPS in order inside the curses screen; return the first bad rc.
Returns 0 when every step succeeded, otherwise the first non-zero exit
- code (a cancelled run returns a non-zero code too).
+ code (a cancelled run returns a non-zero code too). With WAIT_ON_FINISH
+ False the view returns to the caller as soon as the run reaches a
+ terminal phase instead of waiting for a key press (used by the hub's
+ start/stop actions, which land straight back on the menu).
"""
- view = TaskView(scr, title, steps)
+ view = TaskView(scr, title, steps, wait_on_finish=wait_on_finish)
return view.run()
@@ -192,12 +196,14 @@ class TaskView:
"""Draws and drives one list of setup steps; see the module docstring."""
def __init__(self, scr, title: str, steps: List[TaskStep],
- clock: Callable[[], float] = time.time):
+ clock: Callable[[], float] = time.time,
+ wait_on_finish: bool = True):
import curses
self.curses = curses
self.scr = scr
self.title = title
self.steps = steps
+ self.wait_on_finish = wait_on_finish
self.theme = tui._ensure_theme(curses)
self._clock = clock
# -- state -----------------------------------------------------
@@ -330,6 +336,8 @@ class TaskView:
while True:
self._drain()
self.render()
+ if self.phase in _TERMINAL and not self.wait_on_finish:
+ return self._result_rc()
key = self._get_key()
if key is None:
continue
@@ -458,14 +466,15 @@ class TaskView:
break
# -- footer ----------------------------------------------------
+ suffix = "" if not self.wait_on_finish else " — press any key to return"
if self.phase == "done":
- footer = "completed — press any key to return"
+ footer = "completed" + suffix
kind = "ok"
elif self.phase == "cancelled":
- footer = "cancelled — press any key to return"
+ footer = "cancelled" + suffix
kind = "warn"
elif self.phase == "error":
- footer = "finished with errors — press any key to return"
+ footer = "finished with errors" + suffix
kind = "err"
elif self.cancelling:
footer = "cancelling..."
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 0d47863..34c9a3e 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -449,6 +449,9 @@ class Frame:
selected = logical == self.cursor and row["selectable"]
if selected:
_addstr(scr, y, inner_x, " " * inner_w, theme["bar"])
+ if row["align"] == "left":
+ _addch(scr, y, inner_x + self.LIST_MARGIN - 1,
+ self.curses.ACS_RARROW, theme["bar"])
if row["segments"] is not None:
self._draw_segments_row(y, row, inner_x, inner_w, selected)
else: