Kaynağa Gözat

Changed appearance

Efren Yevale Varela 1 ay önce
ebeveyn
işleme
db6513d4aa
2 değiştirilmiş dosya ile 302 ekleme ve 9 silme
  1. 18 4
      src/stt_daemon/tui/app.py
  2. 284 5
      tests/test_tui_app.py

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

@@ -179,6 +179,7 @@ class AssistantTUI(App):
                 triage_log.clear()
                 self._seen_triage_epoch = state.triage_epoch
                 triage_log.write("\n".join(state.triage_lines()))
+        self._update_main_visibility()
 
     def _send(self, type_: str, **fields) -> None:
         try:
@@ -211,6 +212,22 @@ class AssistantTUI(App):
         cmd.focus()
         cmd.cursor_position = len(cmd.value)
 
+    def _chat_has_content(self) -> bool:
+        state = self.state
+        if state.mode in ("chat", "executing"):
+            return True
+        if state.tool is not None or state.tool_result is not None or state.last_error is not None:
+            return True
+        return any(not from_chat for _, from_chat in state.lower)
+
+    def _update_main_visibility(self) -> None:
+        if not self.triage_enabled or self.screen.maximized is not None:
+            return
+        if self.query("#triage"):
+            self.query_one("#triage").display = True
+        if self.query("#chat"):
+            self.query_one("#chat").display = self._chat_has_content()
+
     def _active_pane(self):
         focused = self.screen.focused
         if focused is not None:
@@ -225,10 +242,7 @@ class AssistantTUI(App):
         return None
 
     def _restore_panes(self) -> None:
-        if self.query("#triage"):
-            self.query_one("#triage").display = True
-        if self.query("#chat"):
-            self.query_one("#chat").display = True
+        self._update_main_visibility()
 
     def action_toggle_maximize(self) -> None:
         screen = self.screen

+ 284 - 5
tests/test_tui_app.py

@@ -677,6 +677,13 @@ def test_triage_enabled_layout_proportions(server):
     async def scenario(size=None):
         kwargs = {"size": size} if size else {}
         async with app.run_test(**kwargs) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            # Chat needs content to be shown (hidden-by-default rule): put it in
+            # chat mode so the 1/3-2/3 split applies.
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app._render()
             await pilot.pause()
             main = app.query_one("#main", Horizontal)
             transcript = app.query_one("#transcript", RichLog)
@@ -911,7 +918,7 @@ def test_maximize_triage_hides_chat_and_fills_screen(server):
 
 
 def test_maximize_toggle_restores_layout(server):
-    """Press m again: minimize restores both panes."""
+    """Press m again: minimize restores panes; chat follows the content rule (hidden)."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -937,7 +944,8 @@ def test_maximize_toggle_restores_layout(server):
     asyncio.run(scenario())
     assert observed["maximized"] is None
     assert observed["triage_display"] is True
-    assert observed["chat_display"] is True
+    # No chat/command output ingested: chat follows the content rule → hidden.
+    assert observed["chat_display"] is False
 
 
 def test_maximize_transcript_fills_screen(server):
@@ -1003,7 +1011,7 @@ def test_escape_while_maximized_restores_panes(server):
             for _ in range(10):
                 await pilot.pause()
                 await asyncio.sleep(0.05)
-                if app.screen.maximized is None and app.query_one("#chat", RichLog).display:
+                if app.screen.maximized is None:
                     break
             observed["maximized"] = app.screen.maximized
             observed["triage_display"] = app.query_one("#triage", RichLog).display
@@ -1012,7 +1020,8 @@ def test_escape_while_maximized_restores_panes(server):
     asyncio.run(scenario())
     assert observed["maximized"] is None
     assert observed["triage_display"] is True
-    assert observed["chat_display"] is True
+    # No chat/command output ingested: chat follows the content rule → hidden.
+    assert observed["chat_display"] is False
 
 
 def test_maximize_chat_when_triage_disabled(server):
@@ -1280,7 +1289,8 @@ def test_plain_m_maximizes_triage_pane(server):
             await pilot.pause()
             assert app.screen.maximized is None
             assert app.query_one("#triage", RichLog).display is True
-            assert app.query_one("#chat", RichLog).display is True
+            # No chat/command output ingested: chat follows the content rule → hidden.
+            assert app.query_one("#chat", RichLog).display is False
 
     asyncio.run(scenario())
 
@@ -1310,3 +1320,272 @@ def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
     assert os.path.getsize(files[0]) > 0
     assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \
         "screenshot leaked into %s instead of screenshots/" % tmp_path
+
+
+def test_chat_hidden_by_default_triage_fills_main(server):
+    """Fresh state: #chat hidden, #triage fills the whole #main width."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["triage_display"] = app.query_one("#triage", RichLog).display
+            observed["main_region"] = app.query_one("#main", Horizontal).region
+            observed["triage_region"] = app.query_one("#triage", RichLog).region
+
+    asyncio.run(scenario())
+    print("hidden: main=%s triage=%s" % (observed["main_region"], observed["triage_region"]))
+    assert observed["chat_display"] is False
+    assert observed["triage_display"] is True
+    assert (
+        abs(observed["triage_region"].width - observed["main_region"].width) <= 2
+    ), (observed["triage_region"], observed["main_region"])
+
+
+def test_chat_mode_shows_chat_and_restores_split(server):
+    """mode=chat → #chat visible, triage back to ~1/3 width."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["main_region"] = app.query_one("#main", Horizontal).region
+            observed["triage_region"] = app.query_one("#triage", RichLog).region
+            observed["chat_region"] = app.query_one("#chat", RichLog).region
+
+    asyncio.run(scenario())
+    print(
+        "shown: main=%s triage=%s chat=%s"
+        % (observed["main_region"], observed["triage_region"], observed["chat_region"])
+    )
+    assert observed["chat_display"] is True
+    main_w = observed["main_region"].width
+    assert abs(observed["triage_region"].width / main_w - 1 / 3) < 0.08, observed
+    assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed
+
+
+def test_tool_result_shows_chat(server):
+    """Command output (tool_result) → #chat visible."""
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest(
+                {
+                    "type": "tool_result",
+                    "tool": "get_datetime",
+                    "ok": True,
+                    "result": "2026-09-21",
+                }
+            )
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["chat_display"] is True
+
+
+def test_error_event_shows_chat(server):
+    """An error event → #chat visible."""
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["chat_display"] is True
+
+
+def test_executing_mode_shows_chat(server):
+    """Assistant executing a command → #chat visible."""
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest({"type": "mode", "mode": "executing"})
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["chat_display"] is True
+
+
+def test_cleared_hides_chat_and_restores_full_width(server):
+    """cleared event → #chat hidden again, triage full width."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest(
+                {
+                    "type": "tool_result",
+                    "tool": "get_datetime",
+                    "ok": True,
+                    "result": "2026-09-21",
+                }
+            )
+            app._render()
+            await pilot.pause()
+            assert app.query_one("#chat", RichLog).display is True
+            app.state.ingest({"type": "cleared"})
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["main_region"] = app.query_one("#main", Horizontal).region
+            observed["triage_region"] = app.query_one("#triage", RichLog).region
+
+    asyncio.run(scenario())
+    print(
+        "cleared: main=%s triage=%s"
+        % (observed["main_region"], observed["triage_region"])
+    )
+    assert observed["chat_display"] is False
+    assert (
+        abs(observed["triage_region"].width - observed["main_region"].width) <= 2
+    ), (observed["triage_region"], observed["main_region"])
+
+
+def test_triage_disabled_chat_stays_visible(server):
+    """Triage disabled: #chat stays visible, no crash on render/restore."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app._render()
+            await pilot.pause()
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            app._restore_panes()  # must not raise with no #triage/#main
+            await pilot.pause()
+            observed["chat_display_after"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["chat_display"] is True
+    assert observed["chat_display_after"] is True
+
+
+def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
+    """With ingested output, m then m restores with #chat still visible."""
+    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(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest(
+                {
+                    "type": "tool_result",
+                    "tool": "get_datetime",
+                    "ok": True,
+                    "result": "2026-09-21",
+                }
+            )
+            app._render()
+            await pilot.pause()
+            assert app.query_one("#chat", RichLog).display is True
+            app.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            assert app.screen.maximized is not None
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized"] = app.screen.maximized
+            observed["triage_display"] = app.query_one("#triage", RichLog).display
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["maximized"] is None
+    assert observed["triage_display"] is True
+    assert observed["chat_display"] is True