This batches the in-flight [Unreleased] block from CHANGELOG.md into a single commit. Highlights: - Real MCP protocol layer (initialize / tools/list / tools/call) so vendor MCP clients can complete the handshake against the per-PID socket. Legacy direct-dispatch preserved for the harness. - New mcp_injection kinds — cli_override for codex, config_env for opencode — joining the existing env-var and config_file paths so patterm can slot into more agents without touching their real config or auth. - Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab process lists, recognised in legacy / kitty CSI u / xterm modifyOtherKeys encodings. - Palette macros (sw / k / sp ) and reordering so open sessions surface above spawn-new entries. - Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe on agent spawn, CR-terminated orchestrator injections, and split- Enter PTY writes so paste-detecting TUIs see Enter as a key event. Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion emits CSI 0 J, which the viewport renderer was forwarding verbatim — wiping the sidebar to the right of the cursor and leaving the chrome cache convinced nothing had changed. CSI 0 J and CSI 1 J are now translated into per-row ECH sequences clamped to the viewport, same as CSI 2 J and CSI K already were. Agent guides (CLAUDE.md / AGENTS.md) now spell out the TODO->CHANGELOG workflow so completed items land in the changelog rather than as ticked entries left behind in TODO.
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package app
|
|
|
|
import "testing"
|
|
|
|
func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) {
|
|
root1 := testChild("c1", "root1", "", StatusRunning)
|
|
child1 := testChild("c2", "child1", "c1", StatusRunning)
|
|
root2 := testChild("c3", "root2", "", StatusRunning)
|
|
child2 := testChild("c4", "child2", "c3", StatusRunning)
|
|
|
|
got := visibleSessionTree([]*Child{root1, child1, root2, child2}, "c4")
|
|
if len(got) != 2 || got[0].ID != "c3" || got[1].ID != "c4" {
|
|
t.Fatalf("visible tree = %v, want root2 + child2", childIDs(got))
|
|
}
|
|
}
|
|
|
|
func TestVisibleSessionTreeOmitsExited(t *testing.T) {
|
|
root := testChild("c1", "root", "", StatusRunning)
|
|
exitedRoot := testChild("c2", "dead-root", "", StatusExited)
|
|
runningChild := testChild("c3", "child", "c1", StatusRunning)
|
|
exitedChild := testChild("c4", "dead-child", "c1", StatusExited)
|
|
|
|
got := visibleSessionTree([]*Child{root, exitedRoot, runningChild, exitedChild}, "c1")
|
|
if len(got) != 2 || got[0].ID != "c1" || got[1].ID != "c3" {
|
|
t.Fatalf("visible tree = %v, want running root + running child", childIDs(got))
|
|
}
|
|
}
|
|
|
|
func testChild(id, name, parent string, status ChildStatus) *Child {
|
|
c := &Child{ID: id, Name: name, ParentID: parent}
|
|
c.status.Store(&status)
|
|
return c
|
|
}
|
|
|
|
func childIDs(cs []*Child) []string {
|
|
ids := make([]string, 0, len(cs))
|
|
for _, c := range cs {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
|
r1 := testChild("c1", "root1", "", StatusRunning)
|
|
r2 := testChild("c2", "root2", "", StatusRunning)
|
|
r3 := testChild("c3", "root3", "", StatusRunning)
|
|
children := []*Child{r1, r2, r3}
|
|
|
|
if got := nextTabID(children, "c1", +1); got != "c2" {
|
|
t.Fatalf("next from c1: %q", got)
|
|
}
|
|
if got := nextTabID(children, "c1", -1); got != "c3" {
|
|
t.Fatalf("prev from c1: %q", got)
|
|
}
|
|
if got := nextTabID(children, "c3", +1); got != "c1" {
|
|
t.Fatalf("wrap forward from c3: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
|
r1 := testChild("c1", "root1", "", StatusRunning)
|
|
r1Child := testChild("c2", "child1", "c1", StatusRunning)
|
|
r2 := testChild("c3", "root2", "", StatusRunning)
|
|
children := []*Child{r1, r1Child, r2}
|
|
|
|
// Focus is on a sub-agent of root1; Ctrl+D should jump to root2,
|
|
// not stay inside root1's sub-tree.
|
|
if got := nextTabID(children, "c2", +1); got != "c3" {
|
|
t.Fatalf("next from sub-agent: %q want c3", got)
|
|
}
|
|
}
|
|
|
|
func TestNextChildIDCyclesWithinTab(t *testing.T) {
|
|
r1 := testChild("c1", "root1", "", StatusRunning)
|
|
a := testChild("c2", "a", "c1", StatusRunning)
|
|
b := testChild("c3", "b", "c1", StatusRunning)
|
|
other := testChild("c4", "other-root", "", StatusRunning)
|
|
children := []*Child{r1, a, b, other}
|
|
|
|
if got := nextChildID(children, "c1", +1); got != "c2" {
|
|
t.Fatalf("root->first child: %q", got)
|
|
}
|
|
if got := nextChildID(children, "c2", +1); got != "c3" {
|
|
t.Fatalf("a->b: %q", got)
|
|
}
|
|
if got := nextChildID(children, "c3", +1); got != "c1" {
|
|
t.Fatalf("wrap b->root: %q", got)
|
|
}
|
|
if got := nextChildID(children, "c1", -1); got != "c3" {
|
|
t.Fatalf("wrap backward root->b: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
|
|
r := testChild("c1", "solo", "", StatusRunning)
|
|
if got := nextChildID([]*Child{r}, "c1", +1); got != "" {
|
|
t.Fatalf("expected empty when only one process in tab, got %q", got)
|
|
}
|
|
}
|