Prechádzať zdrojové kódy

Fixed tab key for maximized panes

Efren Yevale Varela 1 mesiac pred
rodič
commit
169be19ba2
3 zmenil súbory, kde vykonal 91 pridanie a 3 odobranie
  1. 1 1
      docs/ASSISTANT.md
  2. 36 2
      src/stt_daemon/tui/app.py
  3. 54 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, 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.
+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. While maximized, `Tab` / `Shift+Tab` cycle focus between the pane and its docked input.
 
 `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.
 

+ 36 - 2
src/stt_daemon/tui/app.py

@@ -78,6 +78,7 @@ class AssistantTUI(App):
         self._seen_questions_epoch = 0
         self._show_questions = False
         self._lower_view = "hidden"
+        self._maximized_input = None
         self._seen_view = "triage"
         self._timer = None
         self._last_seg_title = ""
@@ -335,6 +336,7 @@ class AssistantTUI(App):
         return None
 
     def _set_maximized_inputs(self, input_id: str | None) -> None:
+        self._maximized_input = input_id
         if input_id is None:
             self.ALLOW_IN_MAXIMIZED_VIEW = ".__never__"
             self.query_one("#command", Input).display = True
@@ -342,7 +344,39 @@ class AssistantTUI(App):
             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"
+        self.query_one("#chat-input", Input).display = input_id == "chat-input"
+
+    def _maximized_pane_widget(self):
+        maximized = self.screen.maximized
+        if maximized is None:
+            return None
+        if maximized.id == "main":
+            for pane_id in ("triage", "chat", "output"):
+                if self.query(f"#{pane_id}") and self.query_one(f"#{pane_id}").display:
+                    return self.query_one(f"#{pane_id}")
+            return None
+        return maximized
+
+    def action_focus_next(self) -> None:
+        self._maximized_focus(1)
+
+    def action_focus_previous(self) -> None:
+        self._maximized_focus(-1)
+
+    def _maximized_focus(self, direction: int) -> None:
+        if self.screen.maximized is None or self._maximized_input is None:
+            if direction > 0:
+                self.screen.focus_next()
+            else:
+                self.screen.focus_previous()
+            return
+        input_widget = self.query_one(f"#{self._maximized_input}", Input)
+        if self.focused is input_widget:
+            pane = self._maximized_pane_widget()
+            if pane is not None:
+                pane.focus()
+            return
+        input_widget.focus()
 
     def _restore_panes(self) -> None:
         self._set_maximized_inputs(None)
@@ -358,7 +392,7 @@ class AssistantTUI(App):
         if pane is None:
             return
         if pane.id == "chat":
-            self._set_maximized_inputs("chat")
+            self._set_maximized_inputs("chat-input")
         elif pane.id == "output":
             self._set_maximized_inputs("command")
         else:

+ 54 - 0
tests/test_tui_app.py

@@ -1501,6 +1501,60 @@ def test_maximize_output_keeps_command_input_at_bottom(server):
     assert observed["restored_chat_input"] is True
 
 
+def test_tab_reaches_docked_input_when_maximized(server):
+    """Tab moves focus between the maximized pane and its docked input."""
+    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()
+            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()
+            await pilot.press("tab")
+            await pilot.pause()
+            observed["chat_to_input"] = app.focused.id if app.focused else None
+            await pilot.press("tab")
+            await pilot.pause()
+            observed["chat_back"] = app.focused.id if app.focused else None
+            await pilot.press("m")  # restore
+            await pilot.pause()
+
+            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()
+            await pilot.press("tab")
+            await pilot.pause()
+            observed["output_to_input"] = app.focused.id if app.focused else None
+
+    asyncio.run(scenario())
+    assert observed["chat_to_input"] == "chat-input"
+    assert observed["chat_back"] == "chat"
+    assert observed["output_to_input"] == "command"
+
+
 def test_footer_hidden_while_maximized(server):
     """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
     pytest.importorskip("textual")