Release v0.0.1
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s

Bundles the in-flight work into the first tagged release. See
CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list.
Highlights:

- Sidebar / chrome stability: clamp absolute cursor positioning and
  printable bytes to the viewport so long-running TUIs (claude, codex)
  can't spray into the right rail; bound tab bar's row clear to the
  viewport width so the rail isn't wiped on every tab redraw; flag
  scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K`
  to viewport columns.
- Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill
  entries mark the focused tab, dead agents drop out of the switch
  list.
- Sidebar: split into Processes (session-wide) + Agent Tree
  (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the
  combined list, Ctrl+A/D steps tabs.
- MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`,
  `ping`), `mcp_injection.kind = cli_override / config_env` so codex
  and opencode pick up the server with no file writes, `lifecycle`
  help topic and tool-description cleanup-duty pointers.
- Lifecycle: orchestrator-spawned children cascade-killed when the
  parent dies; orchestrator-injected prompts end with CR + delayed
  Enter so claude submits cleanly.
This commit is contained in:
2026-05-14 22:04:32 +01:00
parent 63f0ddcb38
commit 52e06c914e
18 changed files with 1031 additions and 62 deletions

View File

@@ -5,6 +5,14 @@ import (
"testing"
)
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
@@ -103,6 +111,65 @@ func TestViewportRendererTracksPrintableCursor(t *testing.T) {
}
}
func TestViewportRendererClampsCUPColumn(t *testing.T) {
// Layout: hostCols=120, sidebar present → childCols=91. A child that
// thinks its viewport is the full host width could emit a CUP to col
// 95 (inside the sidebar). The renderer must clamp the emitted CUP
// column so the host cursor never lands in the sidebar.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H")))
if !strings.Contains(got, "\x1b[7;91H") {
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererClampsCHAColumn(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[110G")))
if !strings.Contains(got, "\x1b[91G") {
t.Fatalf("CHA col 110 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererDropsPrintablesPastViewport(t *testing.T) {
// A child whose internal column state drifted past the viewport
// (childCols=91 here) might CUP to col 95 and stream text. The CUP
// column is clamped to the viewport edge, but tracking still
// considers the cursor "past" — so subsequent printables must drop
// rather than walk into the sidebar columns.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95HCLOBBER")))
if strings.Contains(got, "CLOBBER") || strings.Contains(got, "LOBBER") {
t.Fatalf("printables past childCols should be dropped: got %q", got)
}
}
func TestViewportRendererKeepsPrintablesUpToViewportEdge(t *testing.T) {
// Writing exactly childCols glyphs from col 1 must reach the right
// edge unchanged — the drop kicks in only after the cursor passes
// the last viewport column.
vr := newViewportRenderer(newTerminalLayout(120, 40))
in := append([]byte("\x1b[5;1H"), bytesRepeat('x', 91)...)
got := string(vr.Render(in))
if strings.Count(got, "x") != 91 {
t.Fatalf("91 'x' glyphs from col 1 should all be emitted: got %q", got)
}
}
func TestViewportRendererDropsUTF8GlyphPastViewport(t *testing.T) {
// A 3-byte UTF-8 glyph (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) starting
// past the viewport must be dropped as a unit — leaking even one
// continuation byte would feed a malformed sequence to the host.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H─x")))
if strings.Contains(got, "─") {
t.Fatalf("UTF-8 glyph past viewport should be dropped: got %q", got)
}
if strings.Contains(got, "x") {
t.Fatalf("trailing ASCII past viewport should also be dropped: got %q", got)
}
}
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll