Pārlūkot izejas kodu

Extended visual indicators

Efren Yevale Varela 1 mēnesi atpakaļ
vecāks
revīzija
397e95b135

+ 2 - 0
README.md

@@ -603,6 +603,8 @@ queued chat turns.
 
 `c` asks for confirmation, then wipes the conversation / command-output pane and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
 
+**Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation / command output [inf N]` shows the number of LLM inference jobs queued, with `· busy` appearing while one is running. These counters refresh about once per second, driven by the heartbeat cadence.
+
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse
 reporting by default), so dragging does **not** select text the way it normally does in
 your terminal. Hold `Shift` while dragging to bypass the TUI and use your terminal's

+ 5 - 0
src/stt_daemon/assistant/inference.py

@@ -101,6 +101,11 @@ class InferenceQueue:
         with self._lock:
             return len(self._queue)
 
+    def in_flight(self) -> bool:
+        """Return True while a job is executing."""
+        with self._lock:
+            return self._in_flight is not None
+
     def _drop_oldest_normal(self) -> bool:
         """Remove the oldest normal (priority >= 0) job; False if none exists.
 

+ 4 - 0
src/stt_daemon/assistant/service.py

@@ -195,6 +195,8 @@ class AssistantService:
             "speech_open": self.speech_open,
             "awaited_seg_id": self.awaited_seg_id,
             "chat_turns": len(self.chat_history),
+            "inference_depth": self._inference.depth(),
+            "inference_in_flight": self._inference.in_flight(),
         }
 
     # ------------------------------------------------------------------ actions
@@ -431,6 +433,8 @@ class AssistantService:
             worker_stall_s=round(self._worker_stall_s, 3),
             queue_depth=self._queue_depth,
             device_ok=self._device_ok,
+            inference_depth=self._inference.depth(),
+            inference_in_flight=self._inference.in_flight(),
         )
 
     def _on_status(self, now: float) -> None:

+ 17 - 3
src/stt_daemon/tui/app.py

@@ -48,6 +48,8 @@ class AssistantTUI(App):
         self._seen_lower = 0
         self._seen_epoch = 0
         self._timer = None
+        self._last_seg_title = ""
+        self._last_inf_title = ""
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
@@ -58,9 +60,20 @@ class AssistantTUI(App):
         yield Footer()
 
     def _set_titles(self) -> None:
-        self.query_one("#transcript", RichLog).border_title = "transcripts"
         self.query_one("#command", Static).border_title = "command buffer"
-        self.query_one("#chat", RichLog).border_title = "conversation / command output"
+        self._update_titles(self.state)
+
+    def _update_titles(self, state) -> None:
+        seg_title = f"transcripts  [seg {state.queue_depth}]"
+        if seg_title != self._last_seg_title:
+            self.query_one("#transcript", RichLog).border_title = seg_title
+            self._last_seg_title = seg_title
+
+        inf_suffix = " · busy" if state.inference_in_flight else ""
+        inf_title = f"conversation / command output  [inf {state.inference_depth}{inf_suffix}]"
+        if inf_title != self._last_inf_title:
+            self.query_one("#chat", RichLog).border_title = inf_title
+            self._last_inf_title = inf_title
 
     def on_mount(self) -> None:
         self._set_titles()
@@ -101,9 +114,10 @@ class AssistantTUI(App):
             f"[{state.mode_style()} bold]{state.mode}[/]  "
             f"stt:{health}  conn:{conn}  speech:{speaking}  "
             f"worker_stall:{state.worker_stall_s:.1f}s  "
-            f"backlog:{state.queue_depth}  phase:{state.phase}"
+            f"phase:{state.phase}"
         )
         self.query_one("#status", Static).update(status)
+        self._update_titles(state)
 
         edited = " (edited)" if state.command_edited else ""
         self.query_one("#command", Static).update(

+ 6 - 0
src/stt_daemon/tui/state.py

@@ -29,6 +29,8 @@ class ViewState:
     speech_open: bool = False
     worker_stall_s: float = 0.0
     queue_depth: int = 0
+    inference_depth: int = 0
+    inference_in_flight: bool = False
     ready: bool = False
     phase: str = "idle"
     command_text: str = ""
@@ -68,6 +70,10 @@ class ViewState:
             self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
             if "queue_depth" in event:
                 self.queue_depth = int(event["queue_depth"] or 0)
+            if "inference_depth" in event:
+                self.inference_depth = int(event["inference_depth"] or 0)
+            if "inference_in_flight" in event:
+                self.inference_in_flight = bool(event["inference_in_flight"])
             if "device_ok" in event:
                 self.device_ok = bool(event["device_ok"])
         elif etype == "command_text":

+ 26 - 0
tests/test_assistant_service.py

@@ -442,3 +442,29 @@ def test_clear_always_returns_true():
     service.toggle_chat()
     service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
     assert service.clear() is True
+
+
+def test_heartbeat_emits_inference_depth_and_in_flight():
+    """Calling tick() emits heartbeat with inference_depth (int) and inference_in_flight (bool)."""
+    service, emitted, clock = _service()
+    service.tick()
+
+    heartbeats = [e for e in emitted if e["type"] == "heartbeat"]
+    assert len(heartbeats) == 1
+
+    heartbeat = heartbeats[0]
+    assert "inference_depth" in heartbeat
+    assert isinstance(heartbeat["inference_depth"], int)
+    assert "inference_in_flight" in heartbeat
+    assert isinstance(heartbeat["inference_in_flight"], bool)
+
+
+def test_status_contains_inference_depth_and_in_flight():
+    """status() snapshot contains inference_depth (int) and inference_in_flight (bool)."""
+    service, _, _ = _service()
+
+    status = service.status()
+    assert "inference_depth" in status
+    assert isinstance(status["inference_depth"], int)
+    assert "inference_in_flight" in status
+    assert isinstance(status["inference_in_flight"], bool)

+ 27 - 0
tests/test_inference.py

@@ -287,3 +287,30 @@ def test_bound_holds_when_only_priority_jobs_are_present():
     finally:
         release.set()
         queue.stop()
+
+
+def test_in_flight_true_during_job_execution():
+    """in_flight() returns True while a job is executing, False after."""
+    queue = InferenceQueue(size=4)
+    runner = _JobRunner()
+    unblock = threading.Event()
+
+    queue.start()
+
+    job1 = InferenceJob(name="job1", run=runner.job("job1", unblock))
+    queue.submit(job1)
+
+    import time
+    time.sleep(0.01)
+
+    # job1 is in-flight
+    assert queue.in_flight() is True
+    assert queue.depth() == 0
+
+    unblock.set()
+    time.sleep(0.05)
+
+    # job1 finished
+    assert queue.in_flight() is False
+
+    queue.stop()

+ 49 - 0
tests/test_tui_app.py

@@ -284,6 +284,55 @@ def test_tui_records_connect_failure(tmp_path):
     assert "cannot connect" in app.connect_error
 
 
+def test_border_titles_show_queue_and_inference_depths(server):
+    """Border titles for transcript and chat show live segment and inference queue counts."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.state.ingest(
+                {
+                    "type": "heartbeat",
+                    "queue_depth": 5,
+                    "inference_depth": 3,
+                    "inference_in_flight": False,
+                }
+            )
+            app._render()
+            await pilot.pause()
+
+            transcript_title = app.query_one("#transcript", RichLog).border_title
+            chat_title = app.query_one("#chat", RichLog).border_title
+
+            assert "[seg 5]" in transcript_title
+            assert "[inf 3]" in chat_title
+            assert "busy" not in chat_title
+
+            # Test with inference in flight
+            app.state.ingest(
+                {
+                    "type": "heartbeat",
+                    "inference_depth": 7,
+                    "inference_in_flight": True,
+                }
+            )
+            app._render()
+            await pilot.pause()
+
+            chat_title = app.query_one("#chat", RichLog).border_title
+            assert "[inf 7" in chat_title
+            assert "busy" in chat_title
+
+    asyncio.run(scenario())
+
+
 def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
     pytest.importorskip("textual")
     from stt_daemon.tui import app as tui_app

+ 28 - 0
tests/test_tui_state.py

@@ -35,6 +35,34 @@ def test_heartbeat_self_corrects_missed_mode():
     assert state.queue_depth == 2
 
 
+def test_heartbeat_ingests_inference_depth_and_in_flight():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "heartbeat",
+            "inference_depth": 3,
+            "inference_in_flight": True,
+        }
+    )
+    assert state.inference_depth == 3
+    assert state.inference_in_flight is True
+
+
+def test_heartbeat_omitting_inference_fields_preserves_prior_values():
+    state = ViewState()
+    state.inference_depth = 5
+    state.inference_in_flight = True
+    state.ingest(
+        {
+            "type": "heartbeat",
+            "mode": "idle",
+            "stt_ok": True,
+        }
+    )
+    assert state.inference_depth == 5
+    assert state.inference_in_flight is True
+
+
 def test_health_red_when_stt_not_ok():
     state = ViewState()
     state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": False})