Răsfoiți Sursa

Added chat input

Efren Yevale Varela 1 lună în urmă
părinte
comite
a5b9cc1e4d

+ 4 - 2
docs/ASSISTANT.md

@@ -69,8 +69,8 @@ refused unless a TUI is attached.
 ## Control protocol
 
 `stt-ctl` verbs (one message each): `command-start`, `command-execute`, `command-cancel`,
-`command-edit --text "..."`, `chat-toggle`, `clear`, `status`, `speaker-list`,
-`speaker-name <id> <name>`.
+`command-edit --text "..."`, `chat-toggle`, `chat-send --text "..."`, `clear`, `status`,
+`speaker-list`, `speaker-name <id> <name>`.
 
 Clients attach by sending `subscribe` first (`viewer: true` for the TUI, which makes the
 assistant treat it as a watcher for the `require_viewer` gate).
@@ -214,6 +214,8 @@ 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.
+
 `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.
 
 `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).

+ 3 - 0
src/stt_daemon/assistant/control.py

@@ -258,6 +258,9 @@ class ControlServer:
             self._execute(client, command)
         elif command == "chat_toggle":
             self._ack_or_error(client, command, service.toggle_chat(), "cannot toggle chat now")
+        elif command == "chat_send":
+            ok = service.submit_chat_text(str(message.get("text", "")))
+            self._ack_or_error(client, command, ok, "cannot send chat now")
         elif command == "clear":
             self._ack_or_error(client, command, service.clear(), "nothing to clear")
         elif command == "speaker_list":

+ 16 - 3
src/stt_daemon/assistant/service.py

@@ -485,12 +485,25 @@ class AssistantService:
         self._emit_mode()
         return True
 
+    def submit_chat_text(self, text: str) -> bool:
+        """Send one typed chat turn immediately, bypassing the turn-end gate."""
+        text = (text or "").strip()
+        if not text:
+            return False
+        with self._lock:
+            if self.modes.mode is not Mode.CHAT:
+                return False
+            self.chat_history.append(text)
+            self._pending_chat.append(text)
+            self._last_chat_activity = self._now()
+        self._dispatch_chat()
+        return True
+
     def viewer_detached(self) -> bool:
         """Leave chat when the last viewer goes away.
 
-        Chat only exists to be read and driven by an attached viewer, so losing
-        the last one ends the conversation (which clears its history). A no-op in
-        every other mode, where there is nothing to clear.
+        A no-op in every other mode, where there is no chat to leave. The
+        conversation itself survives, so only an explicit `clear` discards it.
         """
         if self.modes.mode is not Mode.CHAT:
             return False

+ 2 - 1
src/stt_daemon/ctl.py

@@ -18,6 +18,7 @@ COMMAND_MAP = {
     "command-cancel": "command_cancel",
     "command-edit": "command_edit",
     "chat-toggle": "chat_toggle",
+    "chat-send": "chat_send",
     "clear": "clear",
     "status": "status",
     "speaker-list": "speaker_list",
@@ -47,7 +48,7 @@ def main() -> int:
     parser = argparse.ArgumentParser(description="Send one command to the running assistant")
     parser.add_argument("command", choices=sorted(COMMAND_MAP))
     parser.add_argument("rest", nargs="*", help="Extra args (speaker-name: <id> <name>)")
-    parser.add_argument("--text", default=None, help="Text for command-edit")
+    parser.add_argument("--text", default=None, help="Text for command-edit / chat-send")
     parser.add_argument("--config", default=None, help="Path to config.toml")
     parser.add_argument("--socket", default=None, help="Override the control socket path")
     args = parser.parse_args()

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

@@ -33,7 +33,9 @@ class AssistantTUI(App):
     CSS = """
     #status { height: 1; }
     #transcript { height: 25%; border: solid $accent; }
-    #command { height: 3; border: solid $accent; }
+    #inputs { height: 3; }
+    #command { width: 2fr; border: solid $accent; }
+    #chat-input { width: 1fr; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
     #output { height: 1fr; border: solid $accent; }
     #main { height: 1fr; }
@@ -85,7 +87,9 @@ class AssistantTUI(App):
     def compose(self) -> ComposeResult:
         yield Static(id="status")
         yield RichLog(id="transcript", wrap=True, markup=False)
-        yield Input(id="command", placeholder="type a command, Enter to run")
+        with Horizontal(id="inputs"):
+            yield Input(id="command", placeholder="type a command, Enter to run")
+            yield Input(id="chat-input", placeholder="type a message, Enter to send")
         if self.triage_enabled:
             with MainPane(id="main"):
                 yield RichLog(id="triage", wrap=True, markup=False)
@@ -98,6 +102,7 @@ class AssistantTUI(App):
 
     def _set_titles(self) -> None:
         self.query_one("#command", Input).border_title = "command buffer"
+        self.query_one("#chat-input", Input).border_title = "chat"
         self._update_titles(self.state)
 
     def _triage_title(self) -> str:
@@ -392,6 +397,9 @@ class AssistantTUI(App):
                 pass
 
     def on_input_submitted(self, event: Input.Submitted) -> None:
+        if event.input.id == "chat-input":
+            self._submit_chat(event.value)
+            return
         if event.input.id != "command":
             return
         text = event.value.strip()
@@ -406,6 +414,19 @@ class AssistantTUI(App):
         self.state.command_text = ""
         self.set_focus(None)
 
+    def _submit_chat(self, value: str) -> None:
+        text = value.strip()
+        if not text:
+            return
+        if self.state.mode == "command_armed":
+            self._send("command_cancel")
+        if self.state.mode != "chat":
+            self._send("chat_toggle")
+        self._lower_view = "chat"
+        self._send("chat_send", text=text)
+        self.query_one("#chat-input", Input).value = ""
+        self.query_one("#chat-input", Input).focus()
+
     def on_unmount(self) -> None:
         if self._timer is not None:
             self._timer.stop()

+ 39 - 0
tests/test_assistant_service.py

@@ -289,6 +289,45 @@ def test_chat_accumulates_turns():
     assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
 
 
+def test_submit_chat_text_requires_chat_and_dispatches_immediately():
+    cfg = Config()
+    cfg.assistant.turn_gap_s = 999.0
+    replies: list[str] = []
+    emitted: list[dict] = []
+
+    def emit(type_, **fields):
+        emitted.append({"type": type_, **fields})
+
+    service = AssistantService(
+        cfg,
+        FakeSubscriber(),
+        emit=emit,
+        chat_fn=lambda text: replies.append(text) or "ok",
+        time_fn=Clock(),
+    )
+
+    assert service.submit_chat_text("hi") is False
+    assert service.submit_chat_text("   ") is False
+
+    service.toggle_chat()
+    service.start()
+    try:
+        assert service.submit_chat_text("hi") is True
+        deadline = time.time() + 2.0
+        while time.time() < deadline and not replies:
+            time.sleep(0.01)
+    finally:
+        service.stop()
+
+    assert replies == ["hi"]
+    assert service.chat_history == ["hi"]
+    assert service._pending_chat == []
+    assert any(
+        e["type"] == "chat_message" and e.get("role") == "user" and e.get("text") == "hi"
+        for e in emitted
+    )
+
+
 def test_execute_clears_command_buffer_immediately():
     service, emitted, clock = _service()
     service.arm()

+ 33 - 0
tests/test_control.py

@@ -230,6 +230,39 @@ def test_chat_toggle_broadcasts_mode(harness):
     client.close()
 
 
+def test_chat_send_acks_and_broadcasts_user_turn(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="chat_toggle")
+    assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
+
+    client.send(type="chat_send", text="hello there")
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "chat_send")
+    assert client.wait_for(
+        lambda m: m["type"] == "chat_message"
+        and m.get("role") == "user"
+        and m.get("text") == "hello there"
+    ) is not None
+    assert harness.service.chat_history == ["hello there"]
+
+    client.close()
+
+
+def test_chat_send_refused_outside_chat(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="chat_send", text="hello")
+    error = client.wait_for(lambda m: m["type"] == "error" and m["code"] == "refused")
+    assert error is not None
+    assert harness.service.chat_history == []
+
+    client.close()
+
+
 def test_last_viewer_disconnect_ends_chat(harness):
     client = Client(harness.path)
     client.send(type="subscribe", viewer=True)

+ 1 - 0
tests/test_ctl.py

@@ -13,6 +13,7 @@ from stt_daemon.ctl import build_message
         ("command-cancel", "command_cancel"),
         ("command-edit", "command_edit"),
         ("chat-toggle", "chat_toggle"),
+        ("chat-send", "chat_send"),
         ("status", "status"),
     ],
 )

+ 127 - 0
tests/test_tui_app.py

@@ -259,6 +259,133 @@ def test_typed_execute_clears_command_input(server):
     assert observed["state_text"] == ""
 
 
+def test_chat_input_enter_activates_chat_sends_and_keeps_focus(server):
+    """Typing in #chat-input + Enter enters chat, sends, clears, and keeps focus."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            app._timer.stop()
+            app._drain()
+            chat_input = app.query_one("#chat-input", Input)
+            chat_input.focus()
+            await pilot.pause()
+            for ch in "hello":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            await pilot.pause()
+            observed["value"] = chat_input.value
+            observed["focused"] = chat_input.has_focus
+            observed["lower_view"] = app._lower_view
+
+    asyncio.run(scenario())
+    assert [t for t, _ in sent] == ["chat_toggle", "chat_send"], sent
+    assert sent[1][1] == {"text": "hello"}
+    assert observed["value"] == ""
+    assert observed["focused"] is True
+    assert observed["lower_view"] == "chat"
+
+
+def test_chat_input_from_chat_sends_without_toggle(server):
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            app._timer.stop()
+            app._drain()
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            chat_input = app.query_one("#chat-input", Input)
+            chat_input.focus()
+            await pilot.pause()
+            for ch in "again":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert [t for t, _ in sent] == ["chat_send"], sent
+
+
+def test_chat_input_enter_activates_chat_and_sends(server):
+    """End-to-end: a typed chat message enters chat mode and lands in history."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    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()
+            chat_input = app.query_one("#chat-input", Input)
+            chat_input.focus()
+            await pilot.pause()
+            for ch in "hello":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            for _ in range(100):
+                await pilot.pause()
+                if service.modes.mode.value == "chat" and service.chat_history:
+                    break
+            observed["mode"] = service.modes.mode.value
+            observed["history"] = list(service.chat_history)
+
+    asyncio.run(scenario())
+    assert observed["mode"] == "chat"
+    assert observed["history"] == ["hello"]
+
+
+def test_command_and_chat_inputs_share_a_row(server):
+    """#chat-input sits to the right of #command on the same row, titled 'chat'."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    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()
+            command = app.query_one("#command", Input)
+            chat = app.query_one("#chat-input", Input)
+            observed["same_row"] = command.region.y == chat.region.y
+            observed["chat_right"] = chat.region.x > command.region.x
+            observed["title"] = chat.border_title
+
+    asyncio.run(scenario())
+    assert observed["same_row"] is True
+    assert observed["chat_right"] is True
+    assert observed["title"] == "chat"
+
+
 def test_empty_command_enter_sends_nothing(server):
     pytest.importorskip("textual")
     from textual.widgets import Input