292 lines
11 KiB
Python
292 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
SRC = (
|
|
Path(__file__).resolve().parents[4]
|
|
/ "src"
|
|
/ "opensquilla"
|
|
/ "cli"
|
|
/ "tui"
|
|
/ "opentui"
|
|
/ "package"
|
|
/ "src"
|
|
)
|
|
|
|
|
|
def _read(rel: str) -> str:
|
|
return (SRC / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
def _node_json(script: str) -> object:
|
|
result = subprocess.run(
|
|
["node", "--input-type=module", "-e", script],
|
|
check=True,
|
|
cwd=Path(__file__).resolve().parents[4],
|
|
text=True,
|
|
encoding="utf-8",
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_host_split_into_block_modules() -> None:
|
|
for f in [
|
|
"theme.mjs",
|
|
"primitives.mjs",
|
|
"blockRegistry.mjs",
|
|
"turnView.mjs",
|
|
"composer.mjs",
|
|
"ipc.mjs",
|
|
"blocks/promptBlock.mjs",
|
|
"blocks/thinkingBlock.mjs",
|
|
"blocks/toolBlock.mjs",
|
|
"blocks/answerBlock.mjs",
|
|
"blocks/usageBlock.mjs",
|
|
"blocks/errorBlock.mjs",
|
|
"main.mjs",
|
|
]:
|
|
assert (SRC / f).exists(), f"missing host module {f}"
|
|
|
|
|
|
def test_registry_covers_seven_kinds() -> None:
|
|
reg = _read("blockRegistry.mjs")
|
|
for kind in ["prompt", "thinking", "reasoning", "tool", "answer", "usage", "error"]:
|
|
assert f"{kind}:" in reg, f"registry missing kind {kind}"
|
|
assert "createBlock" in reg
|
|
|
|
|
|
def test_turn_card_owns_one_continuous_gutter() -> None:
|
|
# The assistant turn renders as ONE card: a single left-border gutter runs
|
|
# unbroken through narration + tool calls, so the timeline reads as one
|
|
# assistant block (opencode/codex style). That gutter is the turn card's
|
|
# border (answerFrame) owned by turnView — in-card blocks no longer redraw
|
|
# their own "│" rail nodes. The chrome is width-independent: a short
|
|
# "╭ squilla" label on top and a bare "╰ …" footer, never a full-width rule
|
|
# (which wraps a stray dash when the scrollbar steals a viewport column).
|
|
tv = _read("turnView.mjs")
|
|
tool = _read("blocks/toolBlock.mjs")
|
|
thinking = _read("blocks/thinkingBlock.mjs")
|
|
assert 'border: ["left"]' in tv
|
|
assert "borderColor: THEME.answerFrame" in tv
|
|
assert '"╭ squilla"' in tv
|
|
assert "╰" in tv
|
|
assert "cardHeaderRule" not in tv
|
|
# the card opens once and closes once per turn (header + footer drawn once)
|
|
assert "openCard" in tv and "closeCard" in tv
|
|
# in-card blocks defer the gutter to the turn card — no per-block rail node
|
|
assert "-rail" not in tool
|
|
assert "-gt" not in thinking
|
|
|
|
|
|
def test_answer_block_is_streaming_markdown_in_the_turn_card() -> None:
|
|
answer = _read("blocks/answerBlock.mjs")
|
|
assert "MarkdownRenderable" in answer
|
|
assert "streaming: true" in answer
|
|
# streaming stops on end()
|
|
assert "md.streaming = false" in answer
|
|
# the card chrome (left border, header label, footer) now belongs to the TURN,
|
|
# not the answer block — the answer is purely the streamed markdown body.
|
|
assert 'border: ["left"]' not in answer
|
|
assert "╭" not in answer and "╰" not in answer
|
|
# the retype mechanism is gone: no teardown contract with turnView
|
|
assert "teardown" not in answer
|
|
|
|
|
|
def test_thinking_block_is_purple_glyph_timeline() -> None:
|
|
thinking = _read("blocks/thinkingBlock.mjs")
|
|
assert "✻" in thinking
|
|
assert "THEME.thinkingAccent" in thinking
|
|
# reasoning renders incrementally as it streams (render called from append)
|
|
assert "append(delta)" in thinking
|
|
assert "render()" in thinking
|
|
# clips to viewport so continuation lines never wrap past the rail
|
|
assert "clipToCells" in thinking
|
|
assert "timelineAvailCells" in thinking
|
|
|
|
|
|
def test_tool_block_groups_detail_and_pulses() -> None:
|
|
tool = _read("blocks/toolBlock.mjs")
|
|
assert "✓" in tool and "✗" in tool
|
|
assert "setGlyph" in tool
|
|
# result preview clipped to viewport (no rail-breaking wrap)
|
|
assert "clipToCells" in tool
|
|
assert "timelineAvailCells" in tool
|
|
# Run-state colors come from the shared STATUS vocabulary (opencode/codex
|
|
# alignment): soft-orange while running, green/red on resolution, dim result.
|
|
assert "STATUS.running" in tool
|
|
assert "STATUS.ok" in tool and "STATUS.error" in tool
|
|
assert "STATUS.detail" in tool
|
|
# codex-style: ONE "└ " result corner (cap), a " · {duration}" suffix on
|
|
# completion, and args inline after the name (no separate args node).
|
|
assert "RESULT_CORNER" in tool and "DURATION_SEP" in tool
|
|
assert "resultAdded" in tool # the single-result-corner guard
|
|
assert "duration" in tool
|
|
|
|
|
|
def test_prompt_and_usage_and_error_blocks() -> None:
|
|
prompt = _read("blocks/promptBlock.mjs")
|
|
usage = _read("blocks/usageBlock.mjs")
|
|
error = _read("blocks/errorBlock.mjs")
|
|
# the prompt is a compact chrome-free row set: a border-left rail box with
|
|
# one muted text node per line — no header rule, no footer
|
|
assert 'border: ["left"]' in prompt
|
|
assert "THEME.promptAccent" in prompt
|
|
assert "THEME.muted" in prompt
|
|
assert "╭" not in prompt and "╰" not in prompt
|
|
assert "·" in usage
|
|
assert "THEME.muted" in usage
|
|
assert "✗" in error
|
|
assert "THEME.error" in error
|
|
|
|
|
|
def test_turnview_routes_block_messages() -> None:
|
|
tv = _read("turnView.mjs")
|
|
assert "createTurnView" in tv
|
|
for method in ["begin(", "append(", "update(", "end(", "refreshPulse("]:
|
|
assert method in tv, f"turnView missing {method}"
|
|
# the retype mechanism is gone: blocks keep their kind for life
|
|
assert "retype" not in tv
|
|
assert "teardown" not in tv
|
|
assert "seedText" not in tv
|
|
# running-tool pulse set is maintained (no dangling animated nodes)
|
|
assert "runningTools" in tv
|
|
|
|
|
|
def test_dispatcher_routes_block_and_legacy_messages() -> None:
|
|
ipc = _read("ipc.mjs")
|
|
assert "createDispatcher" in ipc
|
|
for t in [
|
|
"turn.begin",
|
|
"turn.end",
|
|
"turn.status",
|
|
"composer.set",
|
|
"completion.context",
|
|
"completion.response",
|
|
"router.update",
|
|
"block.begin",
|
|
"block.append",
|
|
"block.update",
|
|
"block.end",
|
|
"prompt.echo",
|
|
"model.text",
|
|
"scrollback.write",
|
|
"notice.write",
|
|
"theme.set",
|
|
"theme.pick",
|
|
"shutdown",
|
|
]:
|
|
assert f'"{t}"' in ipc, f"dispatcher missing case {t}"
|
|
|
|
|
|
def test_composer_input_region_behaviors() -> None:
|
|
composer = _read("composer.mjs")
|
|
assert "createComposer" in composer
|
|
assert "inputHistory" in composer
|
|
assert "syncTerminalCursorToCaret" in composer
|
|
assert "scrollBy" in composer
|
|
# esc cancels the turn; ctrl+C clears-or-eofs; option/meta+return inserts newline
|
|
assert '"escape"' in composer
|
|
assert "input.cancel" in composer
|
|
assert "input.eof" in composer
|
|
assert 'insertAtCursor("\\n")' in composer
|
|
assert ("pageup" in composer) or ("pagedown" in composer)
|
|
|
|
|
|
def test_composer_router_state_carries_structured_fields() -> None:
|
|
composer = _read("composer.mjs")
|
|
# routerState seeds the new structured fields.
|
|
assert "baselineModel" in composer
|
|
assert "rolloutPhase" in composer
|
|
# setRouterState reads the snake_case keys Python sends via asdict.
|
|
assert "baseline_model" in composer
|
|
assert "routing_applied" in composer
|
|
assert "rollout_phase" in composer
|
|
# the model row can render a downgrade marker and source markers exist.
|
|
assert "shortModel" in composer
|
|
assert "↓" in composer
|
|
assert "setCompletionContext" in composer
|
|
|
|
|
|
def test_composer_router_model_downgrade_keeps_target_model_visible() -> None:
|
|
module_path = (
|
|
"./src/opensquilla/cli/tui/opentui/package/src/"
|
|
"composer.mjs"
|
|
)
|
|
prim_path = (
|
|
"./src/opensquilla/cli/tui/opentui/package/src/"
|
|
"primitives.mjs"
|
|
)
|
|
data = _node_json(
|
|
f"""
|
|
const {{ routerStripValue, formatRouterModelValue }} = await import("{module_path}");
|
|
const {{ textWidth }} = await import("{prim_path}");
|
|
const target = "vendor/small-fast";
|
|
const baseline = "vendor/big-heavy";
|
|
const downgrade = formatRouterModelValue(target, baseline);
|
|
const unchanged = formatRouterModelValue(target, target);
|
|
const row = routerStripValue(downgrade);
|
|
const clippedLong = routerStripValue("x".repeat(30));
|
|
console.log(JSON.stringify({{
|
|
downgrade, unchanged, row,
|
|
clippedLong, clippedWidth: textWidth(clippedLong),
|
|
}}));
|
|
"""
|
|
)
|
|
assert data["downgrade"] == "↓ small-fast"
|
|
assert data["unchanged"] == "small-fast"
|
|
assert "small-fast" in data["row"]
|
|
assert "big-heavy" not in data["row"]
|
|
# The live strip clips every value cell to 18 display cells.
|
|
assert data["clippedLong"].endswith("…")
|
|
assert data["clippedWidth"] <= 18
|
|
|
|
|
|
def test_main_is_thin_entry_with_mouse_and_alt_screen() -> None:
|
|
main = _read("main.mjs")
|
|
assert 'screenMode: "alternate-screen"' in main
|
|
assert "useMouse: true" in main
|
|
assert "ScrollBoxRenderable" in main
|
|
assert 'stickyStart: "bottom"' in main
|
|
assert "viewportCulling" in main
|
|
assert "createTurnView" in main
|
|
assert "createComposer" in main
|
|
assert "createDispatcher" in main
|
|
# old monolith artifacts must be gone
|
|
assert "class TurnView" not in main
|
|
assert "OPENTUI_DAILY_THEME" not in main
|
|
assert "answer.demote" not in main
|
|
|
|
|
|
def test_resize_reflows_width_clipped_block_content() -> None:
|
|
# The card chrome is width-independent, so a resize only re-clips block
|
|
# content (tool result corners, narration wraps): main tracks every turn
|
|
# and calls relayout(), which skips entirely on a height-only resize and
|
|
# otherwise defers to each block. No module bakes a width-dependent header
|
|
# rule anymore.
|
|
main = _read("main.mjs")
|
|
turn = _read("turnView.mjs")
|
|
tool = _read("blocks/toolBlock.mjs")
|
|
prompt = _read("blocks/promptBlock.mjs")
|
|
assert "turns" in main and "relayout" in main
|
|
assert "relayout()" in turn and "lastRelayoutWidth" in turn
|
|
assert "relayout()" in tool
|
|
# the compact prompt has nothing width-dependent left to reflow
|
|
assert "relayout()" not in prompt
|
|
# A resize must force a FULL repaint, else OpenTUI's diff-render leaves the old
|
|
# (wider) layout's cells uncleared — the router box bleeds through as stale
|
|
# glyphs when the window shrinks.
|
|
assert "forceFullRepaintRequested" in main
|
|
|
|
|
|
def test_no_legacy_optimistic_demote_in_host() -> None:
|
|
# The optimistic-render + demote/retype model is gone entirely: reasoning
|
|
# and answer arrive as distinct streams, so no block ever changes kind.
|
|
for f in ["main.mjs", "turnView.mjs"]:
|
|
src = _read(f)
|
|
assert "demoteAnswerToTimeline" not in src
|
|
assert "promoteAnswerToCard" not in src
|
|
assert "retype" not in src
|