diff --git a/packages/cothis-tui/src/cothis/tui.py b/packages/cothis-tui/src/cothis/tui.py index 313b557..d5a4b1a 100644 --- a/packages/cothis-tui/src/cothis/tui.py +++ b/packages/cothis-tui/src/cothis/tui.py @@ -1,14 +1,17 @@ """``cothis.tui`` — Textual TUI core (#228). -Adaptive pane layout: - -- ``SessionList`` (left): sessions from the session table. Auto-hidden - in single-session mode (≤1 session listed or attached) so - ``ConversationView`` takes the full width — the sidebar only appears - when the user can actually switch among sessions. -- ``ConversationView`` (center): scrollable Markdown + tool-call cards. -- ``TextArea`` input (bottom, ``id="input"``): multiline input with Ctrl+Enter to send. -- ``CothisFooter`` (very bottom, ``id="footer"``): one-line status bar — +Grid layout (four rows, no dock hacks — every widget is placed by the +grid, ``Header { dock: none }`` overrides its default top-dock): + +- row 1 ``Header`` (1 row): app title bar. +- row 2 ``#main`` (``1fr``): ``SessionList`` (left) + ``ConversationView`` + (center). The sidebar is auto-hidden in single-session mode (≤1 session + listed or attached) so ``ConversationView`` takes the full width — the + sidebar only appears when the user can actually switch among sessions. +- row 3 ``TextArea`` input (``auto``): multiline input with Ctrl+Enter to + send. Auto-grows with content (``min-height: 3``) up to ``max-height: 8``, + then scrolls internally — a long prompt never squeezes the conversation. +- row 4 ``CothisFooter`` (1 row): one-line status bar — model / session short-id / context pressure / active skills / run-state. Stream routing per the design-review sign-off (#228, 2026-07-24): @@ -697,7 +700,6 @@ class CothisFooter(Static): DEFAULT_CSS = """ CothisFooter#footer { height: 1; - dock: bottom; background: $boost; color: $text-disabled; padding: 0 1; @@ -723,18 +725,22 @@ class CothisApp(App): TITLE = "cothis" CSS = """ Screen { - layout: vertical; + layout: grid; + grid-size: 1 4; + grid-rows: 1 1fr auto 1; + grid-columns: 1fr; } - #main { - height: 1fr; + Header { + dock: none; } SessionList > ListItem.active-session { background: $boost; text-style: bold; } TextArea#input { - height: 3; - dock: bottom; + height: auto; + min-height: 3; + max-height: 8; border: round $secondary; } """ diff --git a/pyproject.toml b/pyproject.toml index 4420a57..5cd5fbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,14 @@ members = ["packages/*"] [tool.uv] package = false +# Point ty (CLI + LSP) at the uv workspace venv so third-party imports +# resolve everywhere — including the LSP auxiliary scanner, which launches +# ``ty server`` from a cwd where auto-discovery misses ``.venv``. The +# directory (not interpreter) form keeps this portable across OSes: ty +# locates ``bin/python`` / ``Scripts/python.exe`` inside the venv itself. +[tool.ty.environment] +python = ".venv" + [dependency-groups] dev = [ "pytest>=9.1.1", diff --git a/tests/test_tui.py b/tests/test_tui.py index f6ab119..d48f609 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -31,6 +31,62 @@ async def test_app_launches_with_three_panes() -> None: assert app.query_one("#input") is not None +@pytest.mark.asyncio +async def test_grid_layout_stacks_panes_vertically() -> None: + """The four grid rows stack top-to-bottom: header / main / input / footer. + + The grid (``grid-rows: 1 1fr auto 1``) replaces the old dock-based + layout; every pane must be placed by the grid, so the footer sits + BENEATH the input (not docked above it) and the conversation row owns + the remaining height. + """ + from cothis.tui import ConversationView, CothisApp, CothisFooter + + app = CothisApp() + async with app.run_test(size=(100, 40)) as pilot: + await pilot.pause() + header = app.query_one("Header") + main = app.query_one("#main") + conv = app.query_one(ConversationView) + input_box = app.query_one("#input") + footer = app.query_one(CothisFooter) + assert header.region.y < main.region.y < input_box.region.y < footer.region.y + # Conversation gets the full remaining height: taller than the + # input and footer combined, and it spans the full width in + # single-session mode (sidebar hidden). + assert conv.region.height > input_box.region.height + footer.region.height + assert conv.region.width == app.size.width + + +@pytest.mark.asyncio +async def test_input_auto_grows_then_scrolls_internally() -> None: + """The input ``TextArea`` grows with content, then scrolls past a cap. + + ``height: auto`` sizes the box to the prompt (min 3 rows); past + ``max-height`` it stops growing and scrolls internally, so a long + prompt never squeezes the conversation out of the 1fr row. + """ + from textual.widgets import TextArea + + from cothis.tui import CothisApp + + app = CothisApp() + async with app.run_test(size=(100, 40)) as pilot: + await pilot.pause() + input_box = app.query_one("#input", TextArea) + conv = app.query_one("ConversationView") + idle_conv_height = conv.region.height + idle_input_height = input_box.region.height + assert idle_input_height >= 3 # min-height floor + input_box.text = "\n".join(f"line {i}" for i in range(30)) + await pilot.pause() + assert input_box.region.height > idle_input_height # grew + assert input_box.region.height <= 8 # capped at max-height + assert input_box.max_scroll_y > 0 # internal scroll past the cap + assert conv.region.height < idle_conv_height # conversation shrank + + + @pytest.mark.asyncio async def test_conversation_view_appends_text_delta() -> None: """``append_delta(kind='text', ...)`` accumulates into renderable.""" diff --git a/tests/test_tui_pty.py b/tests/test_tui_pty.py index 4f44033..1ed8240 100644 --- a/tests/test_tui_pty.py +++ b/tests/test_tui_pty.py @@ -38,8 +38,9 @@ def _spawn_tui_pty(cols: int = 90, rows: int = 24) -> tuple[subprocess.Popen, int]: """Spawn ``python -m cothis.tui`` on a fresh PTY; return (proc, master_fd). - The slave is sized via ``TIOCSWINSZ`` so Textual lays out the 3 panes - (input docked at the bottom) instead of falling back to a 0x0 PTY. + The slave is sized via ``TIOCSWINSZ`` so Textual lays out the 4 grid rows + (header / conversation / input / footer) instead of falling back to a + 0x0 PTY. ``TERM=xterm-256color`` makes Textual pick its real terminal driver — the code path the bug lived in. @@ -51,9 +52,9 @@ def _spawn_tui_pty(cols: int = 90, rows: int = 24) -> tuple[subprocess.Popen, in import pty import termios - openpty = getattr(pty, "openpty") - ioctl = getattr(fcntl, "ioctl") - tiocswinsz = getattr(termios, "TIOCSWINSZ") + openpty = getattr(pty, "openpty") # noqa: B009 — deliberate: Windows ty-safe member access + ioctl = getattr(fcntl, "ioctl") # noqa: B009 — deliberate: Windows ty-safe member access + tiocswinsz = getattr(termios, "TIOCSWINSZ") # noqa: B009 — deliberate: Windows ty-safe member access master, slave = openpty() ioctl(slave, tiocswinsz, struct.pack("HHHH", rows, cols, 0, 0))