Explorar o código

Added questions view

Efren Yevale Varela hai 1 mes
pai
achega
2b2a9649c8

+ 3 - 1
docs/ASSISTANT.md

@@ -183,10 +183,12 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 
 ## TUI keys
 ## TUI keys
 
 
-`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `c` clear · `q` quit
+`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `s` questions view · `c` clear · `q` quit
 
 
 `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.
 `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.
 
 
+`s` swaps the triage pane between the live signals view and a structured view of the loaded `questions.json` (one block per question with its id, type, instructions, and criteria); it is a no-op when `[triage].enabled = false`. See [TRIAGE.md](TRIAGE.md).
+
 **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.
 **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
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse

+ 9 - 2
docs/TRIAGE.md

@@ -30,8 +30,15 @@ opt-in, so a plain `uv sync` and the existing test suite behave exactly as befor
 - **Cleared on reset**: `clear` empties the pane. Leaving chat mode does not.
 - **Cleared on reset**: `clear` empties the pane. Leaving chat mode does not.
 
 
 The panel is a view over events (`stage` of `signals`, `content`, or `cleared`) plus the
 The panel is a view over events (`stage` of `signals`, `content`, or `cleared`) plus the
-cached `triage_state` / `triage_reason` in `status()`. It never feeds anything back into
-the assistant.
+cached `triage_state` / `triage_reason` / `questions` in `status()`. It never feeds anything
+back into the assistant.
+
+Pressing `s` in the TUI swaps the pane to a structured view of the loaded `questions.json`
+and pressing it again swaps back. The view renders one block per qid in file order — the
+qid, its type, the instructions, and the criteria (choice labels, a label-to-description
+map, ordered score levels, or the `true`/`false` descriptions of a `noul` question). The
+questions come from the assistant's validated payload (the same dict Laya uses), not from
+disk, and it is a no-op when `[triage].enabled = false`.
 
 
 ## How the cascade works
 ## How the cascade works
 
 

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

@@ -233,6 +233,7 @@ class AssistantService:
             "triage_state": triage_state,
             "triage_state": triage_state,
             "triage_reason": triage_reason,
             "triage_reason": triage_reason,
             "triage": self.last_triage,
             "triage": self.last_triage,
+            "questions": self._triage.questions if self._triage is not None else None,
         }
         }
 
 
     # ------------------------------------------------------------------ actions
     # ------------------------------------------------------------------ actions

+ 33 - 4
src/stt_daemon/tui/app.py

@@ -47,6 +47,7 @@ class AssistantTUI(App):
         Binding("escape", "cancel", "cancel"),
         Binding("escape", "cancel", "cancel"),
         Binding("e", "edit", "edit command"),
         Binding("e", "edit", "edit command"),
         Binding("t", "chat", "chat toggle"),
         Binding("t", "chat", "chat toggle"),
+        Binding("s", "toggle_questions", "questions view"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("q", "quit", "quit"),
         Binding("q", "quit", "quit"),
         Binding("m", "toggle_maximize", "maximize pane", show=False),
         Binding("m", "toggle_maximize", "maximize pane", show=False),
@@ -67,6 +68,9 @@ class AssistantTUI(App):
         self._seen_lower = 0
         self._seen_lower = 0
         self._seen_epoch = 0
         self._seen_epoch = 0
         self._seen_triage_epoch = 0
         self._seen_triage_epoch = 0
+        self._seen_questions_epoch = 0
+        self._show_questions = False
+        self._seen_view = "triage"
         self._timer = None
         self._timer = None
         self._last_seg_title = ""
         self._last_seg_title = ""
         self._last_inf_title = ""
         self._last_inf_title = ""
@@ -86,9 +90,12 @@ class AssistantTUI(App):
     def _set_titles(self) -> None:
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
         self.query_one("#command", Input).border_title = "command buffer"
         if self.triage_enabled:
         if self.triage_enabled:
-            self.query_one("#triage", RichLog).border_title = "triage"
+            self.query_one("#triage", RichLog).border_title = self._triage_title()
         self._update_titles(self.state)
         self._update_titles(self.state)
 
 
+    def _triage_title(self) -> str:
+        return "questions" if self._show_questions else "triage"
+
     def _update_titles(self, state) -> None:
     def _update_titles(self, state) -> None:
         seg_title = f"transcripts  [seg {state.queue_depth}]"
         seg_title = f"transcripts  [seg {state.queue_depth}]"
         if seg_title != self._last_seg_title:
         if seg_title != self._last_seg_title:
@@ -179,11 +186,25 @@ class AssistantTUI(App):
 
 
         if self.triage_enabled:
         if self.triage_enabled:
             triage_log = self.query_one("#triage", RichLog)
             triage_log = self.query_one("#triage", RichLog)
-            if self._seen_triage_epoch != state.triage_epoch:
+            if self._show_questions:
+                view = "questions"
+                epoch = state.questions_epoch
+                seen_epoch = self._seen_questions_epoch
+            else:
+                view = "triage"
+                epoch = state.triage_epoch
+                seen_epoch = self._seen_triage_epoch
+            if self._seen_view != view or seen_epoch != epoch:
                 triage_log.clear()
                 triage_log.clear()
-                self._seen_triage_epoch = state.triage_epoch
-                for item in state.triage_renderables():
+                if self._show_questions:
+                    renderables = state.questions_renderables()
+                    self._seen_questions_epoch = epoch
+                else:
+                    renderables = state.triage_renderables()
+                    self._seen_triage_epoch = epoch
+                for item in renderables:
                     triage_log.write(item)
                     triage_log.write(item)
+                self._seen_view = view
         self._update_main_visibility()
         self._update_main_visibility()
 
 
     def _send(self, type_: str, **fields) -> None:
     def _send(self, type_: str, **fields) -> None:
@@ -207,6 +228,14 @@ class AssistantTUI(App):
     def action_clear(self) -> None:
     def action_clear(self) -> None:
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
 
 
+    def action_toggle_questions(self) -> None:
+        if not self.triage_enabled:
+            return
+        self._show_questions = not self._show_questions
+        if self.query("#triage"):
+            self.query_one("#triage", RichLog).border_title = self._triage_title()
+        self._render()
+
     def _on_clear_confirmed(self, confirmed: bool) -> None:
     def _on_clear_confirmed(self, confirmed: bool) -> None:
         if confirmed:
         if confirmed:
             self._send("clear")
             self._send("clear")

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

@@ -103,6 +103,8 @@ class ViewState:
     triage_reason: str = ""
     triage_reason: str = ""
     triage_epoch: int = 0
     triage_epoch: int = 0
     triage_ts: float | None = None
     triage_ts: float | None = None
+    questions: dict | None = None
+    questions_epoch: int = 0
     speakers: list[dict] = field(default_factory=list)
     speakers: list[dict] = field(default_factory=list)
 
 
     def ingest(self, event: dict) -> None:
     def ingest(self, event: dict) -> None:
@@ -131,6 +133,10 @@ class ViewState:
                 changed = True
                 changed = True
             if changed:
             if changed:
                 self.triage_epoch += 1
                 self.triage_epoch += 1
+            if "questions" in event:
+                # Static config from the analyzer: independent of the triage epoch.
+                self.questions = event["questions"]
+                self.questions_epoch += 1
         elif etype == "triage":
         elif etype == "triage":
             stage = event.get("stage")
             stage = event.get("stage")
             if stage == "cleared":
             if stage == "cleared":
@@ -331,3 +337,69 @@ class ViewState:
         if not lines:
         if not lines:
             return ["triage: no signals"]
             return ["triage: no signals"]
         return lines
         return lines
+
+    def questions_lines(self) -> list[str]:
+        """Render the loaded questions as structured lines. Pure and defensive: never raises."""
+        entries = self._question_entries()
+        if not entries:
+            reason = self.triage_reason or self.triage_state or "no questions loaded"
+            return [f"questions: unavailable — {reason}"]
+        lines: list[str] = []
+        for qid, qtype, body in entries:
+            lines.append(f"{qid}  [{qtype}]")
+            lines.extend(body)
+        return lines
+
+    def questions_renderables(self) -> list[Text]:
+        """questions_lines() as Text, with the qid header styled (bold qid, dim type)."""
+        entries = self._question_entries()
+        if not entries:
+            return [Text(line) for line in self.questions_lines()]
+        out: list[Text] = []
+        for qid, qtype, body in entries:
+            header = Text()
+            header.append(qid, style="bold")
+            header.append("  ")
+            header.append(f"[{qtype}]", style="dim")
+            out.append(header)
+            for line in body:
+                out.append(Text(line))
+        return out
+
+    def _question_entries(self) -> list[tuple[str, str, list[str]]]:
+        """(qid, type, body lines) per usable question, in file order. Never raises."""
+        entries: list[tuple[str, str, list[str]]] = []
+        if not (isinstance(self.questions, dict) and self.questions):
+            return entries
+        for qid, qdef in self.questions.items():
+            if not isinstance(qdef, dict):
+                continue
+            qtype = str(qdef.get("type") or "?")
+            body: list[str] = []
+            instructions = qdef.get("instructions")
+            if isinstance(instructions, str) and instructions:
+                body.append(f"  {instructions}")
+            body.extend(self._criteria_lines(qtype, qdef.get("criteria")))
+            entries.append((str(qid), qtype, body))
+        return entries
+
+    @staticmethod
+    def _criteria_lines(qtype: str, criteria) -> list[str]:
+        """One line per criterion for a known type; malformed criteria degrade to none."""
+        lines: list[str] = []
+        if qtype == "choice":
+            if isinstance(criteria, dict):
+                for label, description in criteria.items():
+                    lines.append(f"    {label}  — {description}")
+            elif isinstance(criteria, list):
+                for label in criteria:
+                    lines.append(f"    {label}")
+        elif qtype == "score":
+            if isinstance(criteria, list):
+                for index, level in enumerate(criteria, start=1):
+                    lines.append(f"    {index}. {level}")
+        elif qtype == "noul":
+            if isinstance(criteria, dict):
+                for value, description in criteria.items():
+                    lines.append(f"    {value}  — {description}")
+        return lines

+ 29 - 0
tests/test_assistant_service.py

@@ -621,6 +621,35 @@ def test_triage_status_reflects_analyzer():
     assert status["triage"] is None
     assert status["triage"] is None
 
 
 
 
+def test_status_contains_questions_from_analyzer():
+    questions = {
+        "urgency": {"type": "noul", "instructions": "how urgent"},
+        "sentiment": {"type": "score", "instructions": "tone", "criteria": ["low", "high"]},
+    }
+    service, _, _, triage, _ = _triage_service(triage=FakeTriage(questions=questions))
+
+    status = service.status()
+
+    assert status["questions"] == questions
+    assert status["questions"] is triage.questions
+
+
+def test_status_questions_none_without_analyzer():
+    service, _, _ = _service()
+
+    assert service.status()["questions"] is None
+
+
+def test_status_event_carries_questions():
+    service, emitted, _, triage, _ = _triage_service()
+
+    service.tick()
+
+    statuses = [e for e in emitted if e["type"] == "status"]
+    assert len(statuses) == 1
+    assert statuses[0]["questions"] is triage.questions
+
+
 def test_triage_captures_transcript_lang():
 def test_triage_captures_transcript_lang():
     service, _, clock, _, _ = _triage_service()
     service, _, clock, _, _ = _triage_service()
     assert service._last_transcript_lang is None
     assert service._last_transcript_lang is None

+ 179 - 3
tests/test_tui_app.py

@@ -1115,7 +1115,7 @@ def test_question_mark_key_toggles_help_panel(server):
 
 
 
 
 def test_app_bindings_list():
 def test_app_bindings_list():
-    """BINDINGS: 7 visible footer entries; hidden ones after; no h binding."""
+    """BINDINGS: 8 visible footer entries; hidden ones after; no h binding."""
     pytest.importorskip("textual")
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI
     from stt_daemon.tui.app import AssistantTUI
 
 
@@ -1128,6 +1128,7 @@ def test_app_bindings_list():
     assert ("escape", "cancel") in pairs
     assert ("escape", "cancel") in pairs
     assert ("e", "edit command") in pairs
     assert ("e", "edit command") in pairs
     assert ("t", "chat toggle") in pairs
     assert ("t", "chat toggle") in pairs
+    assert ("s", "questions view") in pairs
     assert ("c", "clear") in pairs
     assert ("c", "clear") in pairs
     assert ("q", "quit") in pairs
     assert ("q", "quit") in pairs
     assert ("ctrl+p", "instructions") in pairs
     assert ("ctrl+p", "instructions") in pairs
@@ -1138,11 +1139,12 @@ def test_app_bindings_list():
     assert not any(key == "ctrl+k" for key, _ in pairs)
     assert not any(key == "ctrl+k" for key, _ in pairs)
     by_key = {b.key: b for b in AssistantTUI.BINDINGS}
     by_key = {b.key: b for b in AssistantTUI.BINDINGS}
     assert by_key["question_mark"].show is True
     assert by_key["question_mark"].show is True
+    assert by_key["s"].show is True
     assert by_key["m"].show is False
     assert by_key["m"].show is False
     assert by_key["c"].show is False
     assert by_key["c"].show is False
     assert by_key["ctrl+s"].show is False
     assert by_key["ctrl+s"].show is False
     assert by_key["ctrl+p"].show is False
     assert by_key["ctrl+p"].show is False
-    # Visible seven first, in footer order.
+    # Visible eight first, in footer order.
     visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
     visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
     assert visible == [
     assert visible == [
         ("a", "arm command"),
         ("a", "arm command"),
@@ -1150,6 +1152,7 @@ def test_app_bindings_list():
         ("escape", "cancel"),
         ("escape", "cancel"),
         ("e", "edit command"),
         ("e", "edit command"),
         ("t", "chat toggle"),
         ("t", "chat toggle"),
+        ("s", "questions view"),
         ("question_mark", "help"),
         ("question_mark", "help"),
         ("q", "quit"),
         ("q", "quit"),
     ]
     ]
@@ -1157,7 +1160,7 @@ def test_app_bindings_list():
 
 
 
 
 def test_footer_shows_ordered_entries(server):
 def test_footer_shows_ordered_entries(server):
-    """Left list is the 7 visible bindings (? for help); instructions only right-aligned."""
+    """Left list is the 8 visible bindings (? for help); instructions only right-aligned."""
     pytest.importorskip("textual")
     pytest.importorskip("textual")
     from textual.widgets import Footer
     from textual.widgets import Footer
     from textual.widgets._footer import FooterKey
     from textual.widgets._footer import FooterKey
@@ -1191,6 +1194,7 @@ def test_footer_shows_ordered_entries(server):
         ("escape", "cancel"),
         ("escape", "cancel"),
         ("e", "edit command"),
         ("e", "edit command"),
         ("t", "chat toggle"),
         ("t", "chat toggle"),
+        ("s", "questions view"),
         ("question_mark", "help"),
         ("question_mark", "help"),
         ("q", "quit"),
         ("q", "quit"),
     ]
     ]
@@ -1622,3 +1626,175 @@ def test_transcript_pane_keeps_updating_past_cap(server):
     asyncio.run(scenario())
     asyncio.run(scenario())
     assert any("after cap" in line for line in observed["lines"])
     assert any("after cap" in line for line in observed["lines"])
     assert len(observed["lines"]) > MAX_TRANSCRIPTS
     assert len(observed["lines"]) > MAX_TRANSCRIPTS
+
+
+def _questions_payload():
+    return {
+        "topic": {
+            "type": "choice",
+            "instructions": "What is the main theme?",
+            "criteria": {"planning": "roadmap or prioritization"},
+        }
+    }
+
+
+def _rendered_lines(log):
+    return [line.text for line in log.lines]
+
+
+def _ingest_triage_signals(app):
+    app.state.ingest(
+        {
+            "type": "triage",
+            "stage": "signals",
+            "triage": {
+                "order": ["topic"],
+                "items": {
+                    "topic": {
+                        "kind": "choice",
+                        "choice": "planning",
+                        "answer_confidence": 0.91,
+                    }
+                },
+            },
+        }
+    )
+    app.state.ingest(
+        {
+            "type": "status",
+            "triage_state": "ok",
+            "triage_reason": "",
+            "questions": _questions_payload(),
+        }
+    )
+
+
+def test_s_key_swaps_triage_pane_to_questions_view(server):
+    """Press s: #triage border flips to questions and renders the structured blocks."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            _ingest_triage_signals(app)
+            app._render()
+            await pilot.pause()
+            observed["default_title"] = app.query_one("#triage", RichLog).border_title
+            observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["questions_title"] = app.query_one("#triage", RichLog).border_title
+            observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["default_title"] == "triage"
+    assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
+    assert observed["questions_title"] == "questions"
+    rendered = "\n".join(observed["questions_lines"])
+    assert "topic  [choice]" in rendered
+    assert "What is the main theme?" in rendered
+    assert "planning  — roadmap or prioritization" in rendered
+    assert "topic: planning" not in rendered
+
+
+def test_s_key_again_restores_triage_view(server):
+    """Second s: border flips back to triage and the signals view is rewritten."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            _ingest_triage_signals(app)
+            app._render()
+            await pilot.pause()
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["mid_title"] = app.query_one("#triage", RichLog).border_title
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["triage_title"] = app.query_one("#triage", RichLog).border_title
+            observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["mid_title"] == "questions"
+    assert observed["triage_title"] == "triage"
+    rendered = "\n".join(observed["triage_lines"])
+    assert "topic: planning (0.91)" in rendered
+    assert "What is the main theme?" not in rendered
+
+
+def test_s_key_is_noop_when_triage_disabled(server):
+    """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
+    pytest.importorskip("textual")
+    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.action_toggle_questions()
+            await pilot.pause()
+            assert app._show_questions is False
+            assert not app.query("#triage")
+            await pilot.press("s")
+            await pilot.pause()
+            assert app._show_questions is False
+            assert not app.query("#triage")
+
+    asyncio.run(scenario())
+
+
+def test_status_event_questions_populate_questions_view(server):
+    """A status event carrying questions fills state and the toggled pane."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            observed["before_epoch"] = app.state.questions_epoch
+            app.state.ingest({"type": "status", "questions": _questions_payload()})
+            app._render()
+            await pilot.pause()
+            observed["questions"] = app.state.questions
+            observed["epoch"] = app.state.questions_epoch
+
+            await pilot.press("s")
+            await pilot.pause()
+            observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog))
+
+    asyncio.run(scenario())
+    assert observed["questions"] == _questions_payload()
+    assert observed["epoch"] == observed["before_epoch"] + 1
+    assert any("topic  [choice]" in line for line in observed["lines"])

+ 180 - 0
tests/test_tui_state.py

@@ -743,3 +743,183 @@ def test_status_adoption_preserves_last_triage_time():
 
 
     assert state.triage_ts == ts
     assert state.triage_ts == ts
     assert state.triage_renderables()[0].plain == _fmt_clock(ts)
     assert state.triage_renderables()[0].plain == _fmt_clock(ts)
+
+
+def _questions():
+    return {
+        "topic": {
+            "type": "choice",
+            "instructions": "What is the main theme?",
+            "criteria": {
+                "planning": "roadmap or prioritization",
+                "problem": "a blocker or risk",
+            },
+        },
+        "urgency": {
+            "type": "choice",
+            "instructions": "How urgent?",
+            "criteria": ["low", "high"],
+        },
+        "sentiment": {
+            "type": "score",
+            "instructions": "Overall tone?",
+            "criteria": ["negative", "neutral", "positive"],
+        },
+        "blocker": {
+            "type": "noul",
+            "instructions": "A blocker was raised.",
+            "criteria": {"true": "an obstacle was raised", "false": "no obstacle"},
+        },
+        "owner_named": {
+            "type": "noul",
+            "instructions": "A responsible person was named.",
+        },
+    }
+
+
+def test_status_adopts_questions_and_bumps_questions_epoch_only():
+    state = ViewState()
+    triage_epoch = state.triage_epoch
+    questions = _questions()
+    state.ingest({"type": "status", "questions": questions})
+    assert state.questions == questions
+    assert state.questions_epoch == 1
+    assert state.triage_epoch == triage_epoch
+
+
+def test_status_without_questions_preserves_loaded_questions():
+    state = ViewState()
+    questions = _questions()
+    state.ingest({"type": "status", "questions": questions})
+    state.ingest({"type": "status", "mode": "idle", "ready": True})
+    assert state.questions == questions
+    assert state.questions_epoch == 1
+
+
+def test_questions_lines_render_choice_dict_choice_list_score_and_noul():
+    state = ViewState()
+    state.questions = _questions()
+    assert state.questions_lines() == [
+        "topic  [choice]",
+        "  What is the main theme?",
+        "    planning  — roadmap or prioritization",
+        "    problem  — a blocker or risk",
+        "urgency  [choice]",
+        "  How urgent?",
+        "    low",
+        "    high",
+        "sentiment  [score]",
+        "  Overall tone?",
+        "    1. negative",
+        "    2. neutral",
+        "    3. positive",
+        "blocker  [noul]",
+        "  A blocker was raised.",
+        "    true  — an obstacle was raised",
+        "    false  — no obstacle",
+        "owner_named  [noul]",
+        "  A responsible person was named.",
+    ]
+
+
+def test_questions_render_while_triage_lane_is_loading():
+    state = ViewState()
+    state.questions = _questions()
+    state.triage_state = "loading"
+    state.triage_reason = "warming up"
+    assert state.questions_lines()[0] == "topic  [choice]"
+    assert not any("unavailable" in line for line in state.questions_lines())
+
+
+def test_questions_lines_unavailable_when_missing():
+    state = ViewState()
+    state.triage_state = "loading"
+    state.triage_reason = "model warming up"
+    assert state.questions_lines() == ["questions: unavailable — model warming up"]
+
+
+def test_questions_lines_unavailable_falls_back_to_state_then_generic():
+    state = ViewState()
+    assert state.questions_lines() == ["questions: unavailable — disabled"]
+
+    state.triage_state = ""
+    state.triage_reason = ""
+    assert state.questions_lines() == ["questions: unavailable — no questions loaded"]
+
+
+def test_questions_lines_unavailable_for_empty_or_non_dict_payload():
+    state = ViewState()
+    state.triage_state = "no_questions"
+    state.triage_reason = "no questions loaded from questions.json"
+    for payload in (None, {}, [], "junk", 7):
+        state.ingest({"type": "status", "questions": payload})
+        assert state.questions_lines() == [
+            "questions: unavailable — no questions loaded from questions.json"
+        ]
+
+
+def test_questions_lines_malformed_entries_never_raise():
+    state = ViewState()
+    state.questions = {
+        "good": {"type": "choice", "instructions": "pick", "criteria": {"a": "b"}},
+        "not_a_dict": "boom",
+        "no_instructions": {"type": "noul"},
+        "bad_criteria": {"type": "score", "instructions": "level?", "criteria": {}},
+        "empty_criteria": {"type": "choice", "instructions": "choose", "criteria": []},
+        "no_type": {"instructions": "huh"},
+    }
+    lines = state.questions_lines()
+    assert lines == [
+        "good  [choice]",
+        "  pick",
+        "    a  — b",
+        "no_instructions  [noul]",
+        "bad_criteria  [score]",
+        "  level?",
+        "empty_criteria  [choice]",
+        "  choose",
+        "no_type  [?]",
+        "  huh",
+    ]
+
+
+def test_questions_lines_all_malformed_falls_back_to_unavailable():
+    state = ViewState()
+    state.triage_state = "ok"
+    state.questions = {"broken": "not a dict"}
+    assert state.questions_lines() == ["questions: unavailable — ok"]
+
+
+def test_questions_renderables_plain_matches_lines_with_styled_header():
+    state = ViewState()
+    state.questions = _questions()
+    renderables = state.questions_renderables()
+    assert [item.plain for item in renderables] == state.questions_lines()
+    header = renderables[0]
+    assert header.plain == "topic  [choice]"
+    assert [(header.plain[s.start : s.end], str(s.style)) for s in header.spans] == [
+        ("topic", "bold"),
+        ("[choice]", "dim"),
+    ]
+    assert all(not item.spans for item in renderables[1:4])
+
+
+def test_questions_renderables_unavailable_is_plain_single_line():
+    state = ViewState()
+    state.triage_state = "loading"
+    state.triage_reason = "warming up"
+    renderables = state.questions_renderables()
+    assert [item.plain for item in renderables] == [
+        "questions: unavailable — warming up"
+    ]
+    assert not renderables[0].spans
+
+
+def test_clear_output_keeps_questions():
+    state = ViewState()
+    state.questions = _questions()
+    state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
+    state.clear_output()
+    assert state.questions == _questions()
+    assert state.questions_epoch == 0
+    assert state.questions_lines()[0] == "topic  [choice]"