Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions packages/cothis-tui/src/cothis/tui.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -697,7 +700,6 @@ class CothisFooter(Static):
DEFAULT_CSS = """
CothisFooter#footer {
height: 1;
dock: bottom;
background: $boost;
color: $text-disabled;
padding: 0 1;
Expand All @@ -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;
}
"""
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions tests/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
11 changes: 6 additions & 5 deletions tests/test_tui_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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))
Expand Down
Loading