|
@@ -134,6 +134,131 @@ def test_type_command_enter_when_armed_skips_start(server):
|
|
|
assert sent[0][1] == {"text": "open calendar"}, sent
|
|
assert sent[0][1] == {"text": "open calendar"}, sent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def test_arm_from_chat_leaves_chat_first(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))
|
|
|
|
|
+ app._timer.stop()
|
|
|
|
|
+ app._drain()
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
|
|
+ await pilot.press("a")
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert [t for t, _ in sent] == ["chat_toggle", "command_start"], sent
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_type_command_enter_from_chat_leaves_chat_first(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() 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"})
|
|
|
|
|
+ app.query_one("#command", Input).focus()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ for ch in "open calendar":
|
|
|
|
|
+ await pilot.press(ch)
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ await pilot.press("enter")
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert [t for t, _ in sent] == [
|
|
|
|
|
+ "chat_toggle",
|
|
|
|
|
+ "command_start",
|
|
|
|
|
+ "command_edit",
|
|
|
|
|
+ "command_execute",
|
|
|
|
|
+ ], sent
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_command_from_chat_mode_reaches_executing(server):
|
|
|
|
|
+ """A command sent while chatting leaves chat and actually executes."""
|
|
|
|
|
+ pytest.importorskip("textual")
|
|
|
|
|
+ from textual.widgets import Input
|
|
|
|
|
+
|
|
|
|
|
+ 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()
|
|
|
|
|
+ assert service.toggle_chat() is True
|
|
|
|
|
+ for _ in range(100):
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ if app.state.mode == "chat":
|
|
|
|
|
+ break
|
|
|
|
|
+ assert app.state.mode == "chat"
|
|
|
|
|
+ app.query_one("#command", Input).focus()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ for ch in "do it":
|
|
|
|
|
+ await pilot.press(ch)
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ await pilot.press("enter")
|
|
|
|
|
+ for _ in range(100):
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ if service.modes.mode.value == "executing":
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert service.modes.mode.value == "executing"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_typed_execute_clears_command_input(server):
|
|
|
|
|
+ """Typing a command and pressing Enter clears the input as soon as it executes."""
|
|
|
|
|
+ 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() as pilot:
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ app.query_one("#command", Input).focus()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ for ch in "do it":
|
|
|
|
|
+ await pilot.press(ch)
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ await pilot.press("enter")
|
|
|
|
|
+ for _ in range(100):
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ if service.modes.mode.value == "executing":
|
|
|
|
|
+ break
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["value"] = app.query_one("#command", Input).value
|
|
|
|
|
+ observed["state_text"] = app.state.command_text
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert service.modes.mode.value == "executing"
|
|
|
|
|
+ assert observed["value"] == ""
|
|
|
|
|
+ assert observed["state_text"] == ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def test_empty_command_enter_sends_nothing(server):
|
|
def test_empty_command_enter_sends_nothing(server):
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import Input
|
|
from textual.widgets import Input
|
|
@@ -289,16 +414,17 @@ def test_submit_blurs_command_and_hotkey_still_fires(server):
|
|
|
observed["value_after_submit"] = app.query_one("#command", Input).value
|
|
observed["value_after_submit"] = app.query_one("#command", Input).value
|
|
|
await pilot.press("t")
|
|
await pilot.press("t")
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
+ observed["lower_view"] = app._lower_view
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
assert [t for t, _ in sent] == [
|
|
assert [t for t, _ in sent] == [
|
|
|
"command_start",
|
|
"command_start",
|
|
|
"command_edit",
|
|
"command_edit",
|
|
|
"command_execute",
|
|
"command_execute",
|
|
|
- "chat_toggle",
|
|
|
|
|
], sent
|
|
], sent
|
|
|
assert observed["focused_after_submit"] is None
|
|
assert observed["focused_after_submit"] is None
|
|
|
assert observed["value_after_submit"] == ""
|
|
assert observed["value_after_submit"] == ""
|
|
|
|
|
+ assert observed["lower_view"] == "output"
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_focus_triage_then_m_maximizes_deterministically(server):
|
|
def test_focus_triage_then_m_maximizes_deterministically(server):
|
|
@@ -350,7 +476,36 @@ def test_c_key_confirms_before_clearing(server):
|
|
|
assert ("clear", {}) in sent
|
|
assert ("clear", {}) in sent
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_c_key_cancel_does_not_clear(server):
|
|
|
|
|
|
|
+def test_c_key_hides_visible_pane(server):
|
|
|
|
|
+ """Confirming clear hides whatever lower pane was showing."""
|
|
|
|
|
+ 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(size=(80, 40)) as pilot:
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ app.state.ingest(
|
|
|
|
|
+ {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
|
|
|
|
|
+ )
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ assert app.query_one("#output", RichLog).display is True
|
|
|
|
|
+ await pilot.press("c")
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ await pilot.press("y")
|
|
|
|
|
+ for _ in range(100):
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ if app._lower_view == "hidden":
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert app._lower_view == "hidden"
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from stt_daemon.tui.app import AssistantTUI
|
|
from stt_daemon.tui.app import AssistantTUI
|
|
|
|
|
|
|
@@ -454,15 +609,48 @@ def test_tui_renders_command_output(server):
|
|
|
)
|
|
)
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- log = app.query_one("#chat", RichLog)
|
|
|
|
|
|
|
+ log = app.query_one("#output", RichLog)
|
|
|
return list(log.lines)
|
|
return list(log.lines)
|
|
|
|
|
|
|
|
lines = asyncio.run(scenario())
|
|
lines = asyncio.run(scenario())
|
|
|
assert lines, "command output was not rendered anywhere"
|
|
assert lines, "command output was not rendered anywhere"
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_tui_clears_chat_pane_on_chat_exit(server):
|
|
|
|
|
- """Leaving chat must drop the rendered conversation but keep command output."""
|
|
|
|
|
|
|
+def test_command_output_and_chat_render_in_separate_panes(server):
|
|
|
|
|
+ """Tool output lands in #output and chat turns in #chat, with no crossover."""
|
|
|
|
|
+ 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._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": "cmd"}
|
|
|
|
|
+ )
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
|
|
+ app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ output = [str(line) for line in app.query_one("#output", RichLog).lines]
|
|
|
|
|
+ chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
|
|
|
|
|
+ return output, chat
|
|
|
|
|
+
|
|
|
|
|
+ output, chat = asyncio.run(scenario())
|
|
|
|
|
+ assert any("cmd" in line for line in output)
|
|
|
|
|
+ assert not any("hi" in line for line in output)
|
|
|
|
|
+ assert any("hi" in line for line in chat)
|
|
|
|
|
+ assert not any("cmd" in line for line in chat)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_tui_keeps_chat_pane_on_chat_exit(server):
|
|
|
|
|
+ """Leaving chat keeps the rendered conversation; only clear drops it."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -478,18 +666,27 @@ def test_tui_clears_chat_pane_on_chat_exit(server):
|
|
|
{"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
|
|
{"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
|
|
|
)
|
|
)
|
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
- app.state.ingest({"type": "chat_message", "role": "user", "text": "gone"})
|
|
|
|
|
|
|
+ app.state.ingest({"type": "chat_message", "role": "user", "text": "kept-turn"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
app.state.ingest({"type": "mode", "mode": "idle"})
|
|
app.state.ingest({"type": "mode", "mode": "idle"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- return [str(line) for line in app.query_one("#chat", RichLog).lines]
|
|
|
|
|
|
|
+ chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
|
|
|
|
|
+ output = [str(line) for line in app.query_one("#output", RichLog).lines]
|
|
|
|
|
|
|
|
- lines = asyncio.run(scenario())
|
|
|
|
|
- assert not any("gone" in line for line in lines)
|
|
|
|
|
- assert any("kept" in line for line in lines)
|
|
|
|
|
|
|
+ app.state.ingest({"type": "cleared"})
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ cleared = [str(line) for line in app.query_one("#chat", RichLog).lines]
|
|
|
|
|
+ return chat, output, cleared
|
|
|
|
|
+
|
|
|
|
|
+ chat_lines, output_lines, cleared = asyncio.run(scenario())
|
|
|
|
|
+ assert any("kept-turn" in line for line in chat_lines)
|
|
|
|
|
+ assert not any("kept-turn" in line for line in output_lines)
|
|
|
|
|
+ assert any("kept" in line for line in output_lines)
|
|
|
|
|
+ assert cleared == []
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
|
|
def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
|
|
@@ -682,6 +879,7 @@ def test_triage_enabled_layout_proportions(server):
|
|
|
app._drain()
|
|
app._drain()
|
|
|
# Chat needs content to be shown (hidden-by-default rule): put it in
|
|
# Chat needs content to be shown (hidden-by-default rule): put it in
|
|
|
# chat mode so the 1/3-2/3 split applies.
|
|
# chat mode so the 1/3-2/3 split applies.
|
|
|
|
|
+ app._lower_view = "chat"
|
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
@@ -1039,11 +1237,11 @@ def test_maximize_chat_when_triage_disabled(server):
|
|
|
async with app.run_test(size=(80, 40)) as pilot:
|
|
async with app.run_test(size=(80, 40)) as pilot:
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # keep the server status from re-hiding #chat
|
|
app._timer.stop() # keep the server status from re-hiding #chat
|
|
|
- # Disabled: no #main/#triage. #chat starts hidden, so give it output
|
|
|
|
|
- # first, then focus it and press m to maximize #chat itself.
|
|
|
|
|
- app.state.ingest(
|
|
|
|
|
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # Disabled: no #main/#triage. #chat starts hidden, so reveal it via
|
|
|
|
|
+ # the cycle, then focus it and press m to maximize #chat itself.
|
|
|
|
|
+ app._lower_view = "chat"
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
|
|
+ app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app.query_one("#chat", RichLog).focus()
|
|
app.query_one("#chat", RichLog).focus()
|
|
@@ -1134,7 +1332,7 @@ def test_app_bindings_list():
|
|
|
assert ("a", "arm command") in pairs
|
|
assert ("a", "arm command") in pairs
|
|
|
assert ("escape", "cancel") in pairs
|
|
assert ("escape", "cancel") in pairs
|
|
|
assert ("e", "edit command") in pairs
|
|
assert ("e", "edit command") in pairs
|
|
|
- assert ("t", "chat toggle") in pairs
|
|
|
|
|
|
|
+ assert ("t", "cycle pane") in pairs
|
|
|
assert ("s", "questions view") in pairs
|
|
assert ("s", "questions view") in pairs
|
|
|
assert ("c", "clear") in pairs
|
|
assert ("c", "clear") in pairs
|
|
|
assert ("q", "quit") in pairs
|
|
assert ("q", "quit") in pairs
|
|
@@ -1158,7 +1356,7 @@ def test_app_bindings_list():
|
|
|
("ctrl+r", "execute"),
|
|
("ctrl+r", "execute"),
|
|
|
("escape", "cancel"),
|
|
("escape", "cancel"),
|
|
|
("e", "edit command"),
|
|
("e", "edit command"),
|
|
|
- ("t", "chat toggle"),
|
|
|
|
|
|
|
+ ("t", "cycle pane"),
|
|
|
("s", "questions view"),
|
|
("s", "questions view"),
|
|
|
("question_mark", "help"),
|
|
("question_mark", "help"),
|
|
|
("q", "quit"),
|
|
("q", "quit"),
|
|
@@ -1200,7 +1398,7 @@ def test_footer_shows_ordered_entries(server):
|
|
|
("ctrl+r", "execute"),
|
|
("ctrl+r", "execute"),
|
|
|
("escape", "cancel"),
|
|
("escape", "cancel"),
|
|
|
("e", "edit command"),
|
|
("e", "edit command"),
|
|
|
- ("t", "chat toggle"),
|
|
|
|
|
|
|
+ ("t", "cycle pane"),
|
|
|
("s", "questions view"),
|
|
("s", "questions view"),
|
|
|
("question_mark", "help"),
|
|
("question_mark", "help"),
|
|
|
("q", "quit"),
|
|
("q", "quit"),
|
|
@@ -1411,6 +1609,7 @@ def test_chat_mode_shows_chat_and_restores_split(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "chat"
|
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
@@ -1430,8 +1629,8 @@ def test_chat_mode_shows_chat_and_restores_split(server):
|
|
|
assert abs(observed["chat_region"].width / main_w - 2 / 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."""
|
|
|
|
|
|
|
+def test_tool_result_shows_output_pane(server):
|
|
|
|
|
+ """Command output (tool_result) goes to #output, leaving #chat hidden."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -1446,6 +1645,7 @@ def test_tool_result_shows_chat(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
app.state.ingest(
|
|
app.state.ingest(
|
|
|
{
|
|
{
|
|
|
"type": "tool_result",
|
|
"type": "tool_result",
|
|
@@ -1456,14 +1656,16 @@ def test_tool_result_shows_chat(server):
|
|
|
)
|
|
)
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
+ observed["output_display"] = app.query_one("#output", RichLog).display
|
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
- assert observed["chat_display"] is True
|
|
|
|
|
|
|
+ assert observed["output_display"] is True
|
|
|
|
|
+ assert observed["chat_display"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_error_event_shows_chat(server):
|
|
|
|
|
- """An error event → #chat visible."""
|
|
|
|
|
|
|
+def test_error_event_shows_output_pane(server):
|
|
|
|
|
+ """An error event goes to #output, leaving #chat hidden."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -1478,17 +1680,20 @@ def test_error_event_shows_chat(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
|
|
app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
+ observed["output_display"] = app.query_one("#output", RichLog).display
|
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
- assert observed["chat_display"] is True
|
|
|
|
|
|
|
+ assert observed["output_display"] is True
|
|
|
|
|
+ assert observed["chat_display"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_executing_mode_shows_chat(server):
|
|
|
|
|
- """Assistant executing a command → #chat visible."""
|
|
|
|
|
|
|
+def test_executing_mode_shows_output_pane(server):
|
|
|
|
|
+ """Assistant executing a command → #output visible, #chat hidden."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -1503,17 +1708,20 @@ def test_executing_mode_shows_chat(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
app.state.ingest({"type": "mode", "mode": "executing"})
|
|
app.state.ingest({"type": "mode", "mode": "executing"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
+ observed["output_display"] = app.query_one("#output", RichLog).display
|
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
- assert observed["chat_display"] is True
|
|
|
|
|
|
|
+ assert observed["output_display"] is True
|
|
|
|
|
+ assert observed["chat_display"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_cleared_hides_chat_and_restores_full_width(server):
|
|
|
|
|
- """cleared event → #chat hidden again, triage full width."""
|
|
|
|
|
|
|
+def test_cleared_hides_pane_empties_output_and_restores_full_width(server):
|
|
|
|
|
+ """cleared empties #output and hides it again, triage full width."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.containers import Horizontal
|
|
from textual.containers import Horizontal
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
@@ -1529,6 +1737,7 @@ def test_cleared_hides_chat_and_restores_full_width(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
app.state.ingest(
|
|
app.state.ingest(
|
|
|
{
|
|
{
|
|
|
"type": "tool_result",
|
|
"type": "tool_result",
|
|
@@ -1539,11 +1748,16 @@ def test_cleared_hides_chat_and_restores_full_width(server):
|
|
|
)
|
|
)
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- assert app.query_one("#chat", RichLog).display is True
|
|
|
|
|
|
|
+ assert app.query_one("#output", RichLog).display is True
|
|
|
app.state.ingest({"type": "cleared"})
|
|
app.state.ingest({"type": "cleared"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
|
|
+ observed["empty_output"] = [
|
|
|
|
|
+ str(line) for line in app.query_one("#output", RichLog).lines
|
|
|
|
|
+ ]
|
|
|
|
|
+ observed["lower_view"] = app._lower_view
|
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
+ observed["output_display"] = app.query_one("#output", RichLog).display
|
|
|
observed["main_region"] = app.query_one("#main", Horizontal).region
|
|
observed["main_region"] = app.query_one("#main", Horizontal).region
|
|
|
observed["triage_region"] = app.query_one("#triage", RichLog).region
|
|
observed["triage_region"] = app.query_one("#triage", RichLog).region
|
|
|
|
|
|
|
@@ -1552,14 +1766,17 @@ def test_cleared_hides_chat_and_restores_full_width(server):
|
|
|
"cleared: main=%s triage=%s"
|
|
"cleared: main=%s triage=%s"
|
|
|
% (observed["main_region"], observed["triage_region"])
|
|
% (observed["main_region"], observed["triage_region"])
|
|
|
)
|
|
)
|
|
|
|
|
+ assert observed["empty_output"] == []
|
|
|
|
|
+ assert observed["lower_view"] == "hidden"
|
|
|
assert observed["chat_display"] is False
|
|
assert observed["chat_display"] is False
|
|
|
|
|
+ assert observed["output_display"] is False
|
|
|
assert (
|
|
assert (
|
|
|
abs(observed["triage_region"].width - observed["main_region"].width) <= 2
|
|
abs(observed["triage_region"].width - observed["main_region"].width) <= 2
|
|
|
), (observed["triage_region"], observed["main_region"])
|
|
), (observed["triage_region"], observed["main_region"])
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_triage_disabled_chat_follows_content_rule(server):
|
|
|
|
|
- """Triage disabled: #chat is hidden until chat mode or command output arrives."""
|
|
|
|
|
|
|
+def test_cycle_lower_switches_panes(server):
|
|
|
|
|
+ """t cycles hidden -> #output -> #chat -> hidden when triage is disabled."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -1569,6 +1786,13 @@ def test_triage_disabled_chat_follows_content_rule(server):
|
|
|
app = AssistantTUI(path)
|
|
app = AssistantTUI(path)
|
|
|
observed: dict = {}
|
|
observed: dict = {}
|
|
|
|
|
|
|
|
|
|
+ def snap():
|
|
|
|
|
+ return (
|
|
|
|
|
+ app.query_one("#output", RichLog).display,
|
|
|
|
|
+ app.query_one("#chat", RichLog).display,
|
|
|
|
|
+ app.query_one("#transcript", RichLog).has_class("-grow"),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
async def scenario():
|
|
async def scenario():
|
|
|
async with app.run_test(size=(80, 40)) as pilot:
|
|
async with app.run_test(size=(80, 40)) as pilot:
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
@@ -1576,33 +1800,120 @@ def test_triage_disabled_chat_follows_content_rule(server):
|
|
|
app._drain()
|
|
app._drain()
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
- observed["transcript_grown"] = app.query_one("#transcript", RichLog).has_class(
|
|
|
|
|
- "-grow"
|
|
|
|
|
|
|
+ observed["hidden"] = snap()
|
|
|
|
|
+ app.action_cycle_lower()
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["output"] = snap()
|
|
|
|
|
+ app.action_cycle_lower()
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["chat"] = snap()
|
|
|
|
|
+ app.action_cycle_lower()
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["hidden_again"] = snap()
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert observed["hidden"] == (False, False, True)
|
|
|
|
|
+ assert observed["output"] == (True, False, False)
|
|
|
|
|
+ assert observed["chat"] == (False, True, False)
|
|
|
|
|
+ assert observed["hidden_again"] == (False, False, True)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_cycle_lower_enters_and_exits_chat_mode(server):
|
|
|
|
|
+ """Selecting conversation sends chat_toggle on; leaving it toggles off but
|
|
|
|
|
+ keeps the rendered turns until an explicit clear."""
|
|
|
|
|
+ pytest.importorskip("textual")
|
|
|
|
|
+ from textual.widgets import RichLog
|
|
|
|
|
+
|
|
|
|
|
+ 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()
|
|
|
|
|
+ app.action_cycle_lower() # hidden -> output
|
|
|
|
|
+ app.action_cycle_lower() # output -> chat
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["after_enter"] = list(sent)
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
|
|
+ app.state.ingest({"type": "chat_message", "role": "user", "text": "remember"})
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ app.action_cycle_lower() # chat -> hidden (mode is chat -> exits)
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["after_exit"] = list(sent)
|
|
|
|
|
+ observed["chat_lines"] = [str(line) for line in app.query_one("#chat", RichLog).lines]
|
|
|
|
|
+
|
|
|
|
|
+ asyncio.run(scenario())
|
|
|
|
|
+ assert observed["after_enter"] == [("chat_toggle", {})]
|
|
|
|
|
+ assert observed["after_exit"] == [("chat_toggle", {}), ("chat_toggle", {})]
|
|
|
|
|
+ assert any("remember" in line for line in observed["chat_lines"])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_command_activity_reveals_output_pane(server):
|
|
|
|
|
+ """Arming, executing, and chatting: command modes force #output; chat does not."""
|
|
|
|
|
+ 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()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["hidden"] = app.query_one("#output", RichLog).display
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "command_armed"})
|
|
|
|
|
+ app._render()
|
|
|
|
|
+ await pilot.pause()
|
|
|
|
|
+ observed["armed"] = (
|
|
|
|
|
+ app._lower_view,
|
|
|
|
|
+ app.query_one("#output", RichLog).display,
|
|
|
|
|
+ app.query_one("#chat", RichLog).display,
|
|
|
)
|
|
)
|
|
|
- app._restore_panes() # must not raise with no #triage/#main
|
|
|
|
|
|
|
+ app._lower_view = "hidden"
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "executing"})
|
|
|
|
|
+ app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- observed["chat_display_after"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
- app.state.ingest(
|
|
|
|
|
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
|
|
|
|
|
|
|
+ observed["executing"] = (
|
|
|
|
|
+ app._lower_view,
|
|
|
|
|
+ app.query_one("#output", RichLog).display,
|
|
|
)
|
|
)
|
|
|
|
|
+ app._lower_view = "chat"
|
|
|
|
|
+ app.state.ingest({"type": "mode", "mode": "chat"})
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- observed["chat_with_output"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
- observed["transcript_grown_with_output"] = app.query_one(
|
|
|
|
|
- "#transcript", RichLog
|
|
|
|
|
- ).has_class("-grow")
|
|
|
|
|
|
|
+ observed["chat"] = (
|
|
|
|
|
+ app._lower_view,
|
|
|
|
|
+ app.query_one("#output", RichLog).display,
|
|
|
|
|
+ app.query_one("#chat", RichLog).display,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
- assert observed["chat_display"] is False
|
|
|
|
|
- assert observed["chat_display_after"] is False
|
|
|
|
|
- assert observed["transcript_grown"] is True
|
|
|
|
|
- assert observed["chat_with_output"] is True
|
|
|
|
|
- assert observed["transcript_grown_with_output"] is False
|
|
|
|
|
|
|
+ assert observed["hidden"] is False
|
|
|
|
|
+ assert observed["armed"] == ("output", True, False)
|
|
|
|
|
+ assert observed["executing"] == ("output", True)
|
|
|
|
|
+ assert observed["chat"] == ("chat", False, True)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
|
|
|
|
|
- """With ingested output, m then m restores with #chat still visible."""
|
|
|
|
|
|
|
+def test_maximize_restore_with_output_keeps_output_visible(server):
|
|
|
|
|
+ """With ingested output, m then m restores with #output still visible."""
|
|
|
pytest.importorskip("textual")
|
|
pytest.importorskip("textual")
|
|
|
from textual.widgets import RichLog
|
|
from textual.widgets import RichLog
|
|
|
|
|
|
|
@@ -1617,6 +1928,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
app._timer.stop() # no background drain: server status must not clobber state
|
|
|
app._drain()
|
|
app._drain()
|
|
|
|
|
+ app._lower_view = "output"
|
|
|
app.state.ingest(
|
|
app.state.ingest(
|
|
|
{
|
|
{
|
|
|
"type": "tool_result",
|
|
"type": "tool_result",
|
|
@@ -1627,7 +1939,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
|
|
|
)
|
|
)
|
|
|
app._render()
|
|
app._render()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
- assert app.query_one("#chat", RichLog).display is True
|
|
|
|
|
|
|
+ assert app.query_one("#output", RichLog).display is True
|
|
|
app.query_one("#triage", RichLog).focus()
|
|
app.query_one("#triage", RichLog).focus()
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
await pilot.press("m")
|
|
await pilot.press("m")
|
|
@@ -1637,12 +1949,12 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
|
|
|
await pilot.pause()
|
|
await pilot.pause()
|
|
|
observed["maximized"] = app.screen.maximized
|
|
observed["maximized"] = app.screen.maximized
|
|
|
observed["triage_display"] = app.query_one("#triage", RichLog).display
|
|
observed["triage_display"] = app.query_one("#triage", RichLog).display
|
|
|
- observed["chat_display"] = app.query_one("#chat", RichLog).display
|
|
|
|
|
|
|
+ observed["output_display"] = app.query_one("#output", RichLog).display
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
assert observed["maximized"] is None
|
|
assert observed["maximized"] is None
|
|
|
assert observed["triage_display"] is True
|
|
assert observed["triage_display"] is True
|
|
|
- assert observed["chat_display"] is True
|
|
|
|
|
|
|
+ assert observed["output_display"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_transcript_pane_keeps_updating_past_cap(server):
|
|
def test_transcript_pane_keeps_updating_past_cap(server):
|
|
@@ -1896,7 +2208,7 @@ def test_triage_disabled_heartbeat_leaves_no_triage_widget(server):
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
asyncio.run(scenario())
|
|
|
assert observed["has_triage"] is False
|
|
assert observed["has_triage"] is False
|
|
|
- assert observed["chat"] == "conversation / command output \\[inf 9 · busy]"
|
|
|
|
|
|
|
+ assert observed["chat"] == "conversation \\[inf 9 · busy]"
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_s_key_is_noop_when_triage_disabled(server):
|
|
def test_s_key_is_noop_when_triage_disabled(server):
|