소스 검색

Added clearing option

Efren Yevale Varela 1 개월 전
부모
커밋
50f5e7f8c0

+ 5 - 3
README.md

@@ -548,13 +548,13 @@ 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`, `status`.
+`command-edit --text "..."`, `chat-toggle`, `clear`, `status`.
 
 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).
 
 Assistant → clients: `mode`, `command_text`, `transcript`, `speech_start`/`speech_stop`,
-`tool_call`, `tool_result`, `chat_message`, `heartbeat`, `error`, plus the direct
+`tool_call`, `tool_result`, `chat_message`, `cleared`, `heartbeat`, `error`, plus the direct
 replies `ack` (command accepted) and `status`.
 
 ### Audit
@@ -599,7 +599,9 @@ queued chat turns.
 
 ### TUI keys
 
-`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `q` quit
+`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `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.
 
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse
 reporting by default), so dragging does **not** select text the way it normally does in

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

@@ -258,6 +258,8 @@ 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 == "clear":
+            self._ack_or_error(client, command, service.clear(), "nothing to clear")
         else:
             self._send(client, "error", code="unknown_command", message=f"unknown: {command}")
 

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

@@ -24,6 +24,7 @@ ERROR_EVENT = "error"
 TOOL_CALL_EVENT = "tool_call"
 TOOL_RESULT_EVENT = "tool_result"
 CHAT_MESSAGE_EVENT = "chat_message"
+CLEARED_EVENT = "cleared"
 
 
 class _ExecuteBusy(str):
@@ -352,6 +353,24 @@ class AssistantService:
             return False
         return self._exit_chat("viewer_left")
 
+    def clear(self) -> bool:
+        """Clear the chat history and emit a cleared event (always returns True).
+
+        Does not change mode. Drops queued chat inference jobs and calls chat_reset
+        if available. Emits a cleared event to notify viewers.
+        """
+        with self._lock:
+            self.chat_history = []
+            self._pending_chat = []
+        self._inference.drop(lambda j: j.name == "chat")
+        if self.chat_reset is not None:
+            try:
+                self.chat_reset()
+            except Exception as e:  # noqa: BLE001 - clearing chat must not fail the operation
+                logger.warning("chat reset failed: %s", e)
+        self._emit(CLEARED_EVENT)
+        return True
+
     # ------------------------------------------------------------------ events
 
     def handle_event(self, event: dict) -> None:

+ 1 - 0
src/stt_daemon/ctl.py

@@ -18,6 +18,7 @@ COMMAND_MAP = {
     "command-cancel": "command_cancel",
     "command-edit": "command_edit",
     "chat-toggle": "chat_toggle",
+    "clear": "clear",
     "status": "status",
 }
 

+ 9 - 0
src/stt_daemon/tui/app.py

@@ -12,6 +12,7 @@ from textual.widgets import Footer, Input, RichLog, Static
 from ..assistant.control import default_control_path
 from ..config import load_config
 from .client import ControlClient
+from .dialogs import ConfirmClear
 from .state import ViewState
 
 
@@ -32,6 +33,7 @@ class AssistantTUI(App):
         Binding("escape", "cancel", "cancel"),
         Binding("e", "edit", "edit"),
         Binding("t", "chat", "chat toggle"),
+        Binding("c", "clear", "clear"),
         Binding("q", "quit", "quit"),
     ]
 
@@ -142,6 +144,13 @@ class AssistantTUI(App):
     def action_chat(self) -> None:
         self._send("chat_toggle")
 
+    def action_clear(self) -> None:
+        self.push_screen(ConfirmClear(), self._on_clear_confirmed)
+
+    def _on_clear_confirmed(self, confirmed: bool) -> None:
+        if confirmed:
+            self._send("clear")
+
     def action_edit(self) -> None:
         edit = self.query_one("#edit", Input)
         edit.value = self.state.command_text

+ 40 - 0
src/stt_daemon/tui/dialogs.py

@@ -0,0 +1,40 @@
+"""Modal dialogs for the assistant TUI."""
+
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.screen import ModalScreen
+from textual.widgets import Button, Static
+
+
+class ConfirmClear(ModalScreen[bool]):
+    """Ask before wiping the conversation / command output."""
+
+    CSS = """
+    ConfirmClear { align: center middle; }
+    #confirm { width: 60; height: auto; border: thick $accent; background: $surface; padding: 1 2; }
+    #confirm-buttons { height: auto; align-horizontal: right; }
+    #confirm-buttons Button { margin-left: 2; }
+    """
+
+    BINDINGS = [
+        Binding("y", "confirm", "yes"),
+        Binding("n", "cancel", "no"),
+        Binding("escape", "cancel", "cancel"),
+    ]
+
+    def compose(self) -> ComposeResult:
+        with Vertical(id="confirm"):
+            yield Static("Clear the conversation and command output?")
+            with Horizontal(id="confirm-buttons"):
+                yield Button("Clear", variant="error", id="yes")
+                yield Button("Cancel", variant="primary", id="no")
+
+    def on_button_pressed(self, event: Button.Pressed) -> None:
+        self.dismiss(event.button.id == "yes")
+
+    def action_confirm(self) -> None:
+        self.dismiss(True)
+
+    def action_cancel(self) -> None:
+        self.dismiss(False)

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

@@ -95,6 +95,8 @@ class ViewState:
         elif etype == "error":
             self.last_error = event
             self.append_lower(f"! {event.get('code')}: {event.get('message')}")
+        elif etype == "cleared":
+            self.clear_output()
 
     def append_lower(self, line: str, from_chat: bool = False) -> None:
         """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
@@ -107,6 +109,15 @@ class ViewState:
         self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
         self.chat_epoch += 1
 
+    def clear_output(self) -> None:
+        """Clear conversation/command output: chat, tool calls/results, errors."""
+        self.chat        = []
+        self.lower       = []
+        self.tool        = None
+        self.tool_result = None
+        self.last_error  = None
+        self.chat_epoch += 1
+
     def _set_mode(self, mode: str) -> None:
         if self.mode == "chat" and mode != "chat":
             self.clear_chat()

+ 48 - 0
tests/test_assistant_service.py

@@ -394,3 +394,51 @@ def test_chat_notify_emits_truncated_error():
     service.chat_notify("truncated", message="cut off")
 
     assert {"type": "error", "code": "truncated", "message": "cut off"} in emitted
+
+
+def test_clear_empties_chat_history():
+    service, emitted, clock = _service()
+    service.toggle_chat()
+
+    for text in ("hello", "how are you"):
+        service.handle_event(_transcript(clock, text))
+
+    assert service.chat_history == ["hello", "how are you"]
+    assert len(service._pending_chat) == 2
+
+    assert service.clear() is True
+    assert service.chat_history == []
+    assert service._pending_chat == []
+    assert {"type": "cleared"} in emitted
+
+
+def test_clear_invokes_chat_reset():
+    cfg = Config()
+    resets: list[int] = []
+    service = AssistantService(
+        cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
+    )
+    service.toggle_chat()
+
+    service.clear()
+
+    assert resets == [1]
+
+
+def test_clear_does_not_change_mode():
+    service, _, clock = _service()
+    service.toggle_chat()
+    assert service.modes.mode.value == "chat"
+
+    service.clear()
+
+    assert service.modes.mode.value == "chat"
+
+
+def test_clear_always_returns_true():
+    service, _, _ = _service()
+    assert service.clear() is True
+
+    service.toggle_chat()
+    service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
+    assert service.clear() is True

+ 23 - 0
tests/test_control.py

@@ -289,6 +289,29 @@ def test_unknown_command_is_rejected(harness):
     client.close()
 
 
+def test_clear_broadcasts_cleared_event(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")
+
+    service = harness.service
+    service.handle_event(_transcript("hello"))
+
+    assert len(service.chat_history) == 1
+
+    client.send(type="clear")
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "clear")
+    assert client.wait_for(lambda m: m["type"] == "cleared") is not None
+
+    assert service.chat_history == []
+    assert service._pending_chat == []
+
+    client.close()
+
+
 def test_control_socket_is_private_and_removed(tmp_path):
     cfg = Config()
     path = str(Path(tempfile.mkdtemp(prefix="sttc-")) / "c.sock")

+ 105 - 0
tests/test_tui_app.py

@@ -66,6 +66,111 @@ def test_enter_sends_execute_and_e_opens_editor(server):
     assert ("command_cancel", {}) in sent
 
 
+def test_c_key_confirms_before_clearing(server):
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+
+            await pilot.press("c")
+            await pilot.pause()
+            assert not any(type_ == "clear" for type_, _ in sent), "clear sent before confirmation"
+
+            await pilot.press("y")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert ("clear", {}) in sent
+
+
+def test_c_key_cancel_does_not_clear(server):
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+
+            await pilot.press("c")
+            await pilot.pause()
+            await pilot.press("n")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert not any(type_ == "clear" for type_, _ in sent)
+
+
+def test_c_key_escape_does_not_clear(server):
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+
+            await pilot.press("c")
+            await pilot.pause()
+            await pilot.press("escape")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert not any(type_ == "clear" for type_, _ in sent)
+
+
+def test_c_key_clears_rendered_output(server):
+    """Pressing 'c' clears the conversation pane via the cleared event."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    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.state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
+            app.state.ingest(
+                {
+                    "type": "tool_result",
+                    "tool": "get_datetime",
+                    "ok": True,
+                    "result": "2026-09-21",
+                }
+            )
+            app._render()
+            await pilot.pause()
+
+            app.state.ingest({"type": "cleared"})
+            app._render()
+            await pilot.pause()
+
+            log = app.query_one("#chat", RichLog)
+            return list(log.lines)
+
+    lines = asyncio.run(scenario())
+    assert not any("hello" in str(line) for line in lines)
+    assert not any("2026-09-21" in str(line) for line in lines)
+
+
 def test_tui_renders_command_output(server):
     """A tool result must actually reach a widget - otherwise 'nothing happens'."""
     pytest.importorskip("textual")

+ 36 - 0
tests/test_tui_state.py

@@ -200,3 +200,39 @@ def test_entering_chat_does_not_clear_rendered_log():
 
     assert state.chat_epoch == epoch
     assert any("ok:" in line for line in state.lower_lines())
+
+
+def test_cleared_event_wipes_output_and_increments_epoch():
+    state = ViewState()
+    state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
+    state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
+    state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
+    state.ingest(
+        {
+            "type": "tool_result",
+            "tool": "get_datetime",
+            "ok": True,
+            "result": "2026-09-21T01:00:00",
+        }
+    )
+    state.ingest({"type": "error", "code": "test_error", "message": "test message"})
+    state.ingest({"type": "transcript", "text": "preserve this", "lang": "en"})
+
+    epoch = state.chat_epoch
+    assert len(state.chat_lines()) == 2
+    assert len(state.lower_lines()) >= 4
+    assert state.tool is not None
+    assert state.tool_result is not None
+    assert state.last_error is not None
+    assert len(state.transcripts) == 1
+
+    state.ingest({"type": "cleared"})
+
+    assert state.chat_lines() == []
+    assert state.lower_lines() == []
+    assert state.tool is None
+    assert state.tool_result is None
+    assert state.last_error is None
+    assert state.chat_epoch == epoch + 1
+    assert len(state.transcripts) == 1
+    assert state.transcript_lines() == ["[en] preserve this"]