Pārlūkot izejas kodu

Changed behavior for commands and chat panes and corresponding inputs

Efren Yevale Varela 1 mēnesi atpakaļ
vecāks
revīzija
939fb2cc98
3 mainītis faili ar 131 papildinājumiem un 2 dzēšanām
  1. 1 1
      docs/ASSISTANT.md
  2. 19 1
      src/stt_daemon/tui/app.py
  3. 111 0
      tests/test_tui_app.py

+ 1 - 1
docs/ASSISTANT.md

@@ -214,7 +214,7 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 `t` cycles the lower panes through command output, conversation, and hidden. Selecting the conversation turns chat mode on; cycling away turns it off. The conversation is kept across those transitions — only `c` discards it. Arming or executing a command (by key or typed input) leaves chat mode if it was on and reveals the command-output pane; this never happens from within chat itself, since a command always pre-empts it. Transcripts are not affected.
 
-The row below the transcript holds two inputs: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first.
+The row below the transcript holds two inputs, split 50/50: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first. Maximizing the conversation pane (`m`) hides the command buffer and keeps the chat box docked at the bottom of the screen; maximizing the command-output pane keeps the command buffer docked there instead.
 
 `c` asks for confirmation, then wipes the conversation and command-output panes, hides whichever of them was showing, and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
 

+ 19 - 1
src/stt_daemon/tui/app.py

@@ -34,7 +34,7 @@ class AssistantTUI(App):
     #status { height: 1; }
     #transcript { height: 25%; border: solid $accent; }
     #inputs { height: 3; }
-    #command { width: 2fr; border: solid $accent; }
+    #command { width: 1fr; border: solid $accent; }
     #chat-input { width: 1fr; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
     #output { height: 1fr; border: solid $accent; }
@@ -44,6 +44,7 @@ class AssistantTUI(App):
     #main #output { width: 2fr; height: 100%; }
     #transcript.-maximized { height: 1fr; }
     #transcript.-grow { height: 1fr; }
+    Screen.-maximized-view #inputs { dock: bottom; }
     """
 
     BINDINGS = [
@@ -333,7 +334,18 @@ class AssistantTUI(App):
             return self.query_one("#transcript")
         return None
 
+    def _set_maximized_inputs(self, input_id: str | None) -> None:
+        if input_id is None:
+            self.ALLOW_IN_MAXIMIZED_VIEW = ".__never__"
+            self.query_one("#command", Input).display = True
+            self.query_one("#chat-input", Input).display = True
+            return
+        self.ALLOW_IN_MAXIMIZED_VIEW = "#inputs"
+        self.query_one("#command", Input).display = input_id == "command"
+        self.query_one("#chat-input", Input).display = input_id == "chat"
+
     def _restore_panes(self) -> None:
+        self._set_maximized_inputs(None)
         self._update_main_visibility()
 
     def action_toggle_maximize(self) -> None:
@@ -345,6 +357,12 @@ class AssistantTUI(App):
         pane = self._active_pane()
         if pane is None:
             return
+        if pane.id == "chat":
+            self._set_maximized_inputs("chat")
+        elif pane.id == "output":
+            self._set_maximized_inputs("command")
+        else:
+            self._set_maximized_inputs(None)
         if pane.id in ("triage", "chat", "output") and self.query("#main"):
             for other_id in ("triage", "chat", "output"):
                 if other_id == pane.id:

+ 111 - 0
tests/test_tui_app.py

@@ -379,11 +379,14 @@ def test_command_and_chat_inputs_share_a_row(server):
             observed["same_row"] = command.region.y == chat.region.y
             observed["chat_right"] = chat.region.x > command.region.x
             observed["title"] = chat.border_title
+            observed["widths"] = (command.region.width, chat.region.width)
 
     asyncio.run(scenario())
     assert observed["same_row"] is True
     assert observed["chat_right"] is True
     assert observed["title"] == "chat"
+    command_w, chat_w = observed["widths"]
+    assert abs(command_w - chat_w) <= 2, observed["widths"]
 
 
 def test_empty_command_enter_sends_nothing(server):
@@ -1390,6 +1393,114 @@ def test_maximize_chat_when_triage_disabled(server):
     assert observed["restored"] is None
 
 
+def test_maximize_chat_keeps_chat_input_at_bottom(server):
+    """Maximizing the conversation keeps only #chat-input, docked at the bottom."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input, 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()
+            app._drain()
+            app._lower_view = "chat"
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
+            app._render()
+            await pilot.pause()
+            app.query_one("#chat", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            chat = app.query_one("#chat", RichLog)
+            chat_input = app.query_one("#chat-input", Input)
+            command = app.query_one("#command", Input)
+            observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
+            observed["chat_input_display"] = chat_input.display
+            observed["command_display"] = command.display
+            observed["chat_region"] = chat.region
+            observed["input_region"] = chat_input.region
+            observed["input_below_chat"] = chat_input.region.y > chat.region.y
+            observed["input_bottom"] = (
+                chat_input.region.y + chat_input.region.height
+                >= app.screen.size.height - 1
+            )
+            await pilot.press("m")
+            await pilot.pause()
+            observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
+            observed["restored_command"] = command.display
+            observed["restored_chat_input"] = chat_input.display
+
+    asyncio.run(scenario())
+    assert observed["allow"] == "#inputs"
+    assert observed["chat_input_display"] is True
+    assert observed["command_display"] is False
+    assert observed["input_below_chat"] is True, observed
+    assert observed["input_bottom"] is True
+    assert observed["restored_allow"] == ".__never__"
+    assert observed["restored_command"] is True
+    assert observed["restored_chat_input"] is True
+
+
+def test_maximize_output_keeps_command_input_at_bottom(server):
+    """Maximizing the command output keeps only #command, docked at the bottom."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input, 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()
+            app._drain()
+            app._lower_view = "output"
+            app.state.ingest(
+                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
+            )
+            app._render()
+            await pilot.pause()
+            app.query_one("#output", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            output = app.query_one("#output", RichLog)
+            command = app.query_one("#command", Input)
+            chat_input = app.query_one("#chat-input", Input)
+            observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
+            observed["command_display"] = command.display
+            observed["chat_input_display"] = chat_input.display
+            observed["input_below_output"] = command.region.y > output.region.y
+            observed["input_bottom"] = (
+                command.region.y + command.region.height >= app.screen.size.height - 1
+            )
+            await pilot.press("m")
+            await pilot.pause()
+            observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
+            observed["restored_command"] = command.display
+            observed["restored_chat_input"] = chat_input.display
+
+    asyncio.run(scenario())
+    assert observed["allow"] == "#inputs"
+    assert observed["command_display"] is True
+    assert observed["chat_input_display"] is False
+    assert observed["input_below_output"] is True, observed
+    assert observed["input_bottom"] is True
+    assert observed["restored_allow"] == ".__never__"
+    assert observed["restored_command"] is True
+    assert observed["restored_chat_input"] is True
+
+
 def test_footer_hidden_while_maximized(server):
     """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
     pytest.importorskip("textual")