Skip to content
Open
17 changes: 17 additions & 0 deletions docs/changes/unreleased/1388-restore-home-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
kind: changed
title: Home returns to double-space navigation and the ask-here choice
pr: 1388
surface: [chat, docs]
invalidates:
- "Escape navigated outward until Home and two spaces only typed text. Two spaces in an empty box open Home again; Escape closes places into the conversation, interrupts its running answer, and double-Escape opens inline rewind."
- "Home showed only search results above the seam and /ask selected a Home exchange. The ask-here and start-a-new-conversation rows are back below the results; Enter defaults to a conversation, Up then Enter asks here, and /ask is removed."
- "Home called its standing-orders panel scheduled. The heading is standing again, matching the place it opens."
---

Restore the two interactions changed in #1071 while keeping subsequent command-menu,
effort-shortcut, question-row, compact-layout and waiting-message fixes. The manual
and terminal test recipes follow the restored keys. Deterministic tmux coverage drives
the built binary at three widths and checks both submission routes with a local endpoint.

Rename Home’s `scheduled` heading back to `standing` to match the tab it opens.
106 changes: 106 additions & 0 deletions internal/e2e/home_restore_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//go:build e2e

package e2e

import (
"os/exec"
"strconv"
"strings"
"testing"
"time"
)

// Home's navigation and submission choices must work before a provider is
// connected. This drives the shipped binary without sending a model request.
func TestHomeRestoredNavigationNoModel(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("no tmux on PATH")
}
for _, width := range []int{180, 120, 44} {
t.Run(strconv.Itoa(width), func(t *testing.T) {
home := newHome(t, nil)
seedProject(t, home, "alpha", 0, time.Minute)
ws := newWorkspace(t, "navigation", false)
r := startFresh(t, "home_restore", home, ws, width, 40, "chat", "--no-host")
r.skipSetup(t)
r.waitFor(20*time.Second, say(t, "placeRestWord"))

r.keys("Escape")
r.waitFor(15*time.Second, say(t, "homeDoorWord"))
r.keys("Space", "Space")
r.waitFor(15*time.Second, say(t, "placeRestWord"))

r.lit("Seed")
screen := r.waitFor(15*time.Second, say(t, "homeAskHereWord"), say(t, "homeStartWord"), "Seed Alpha")
match := strings.Index(screen, "Seed Alpha")
ask := strings.Index(screen, say(t, "homeAskHereWord"))
start := strings.Index(screen, say(t, "homeStartWord"))
box := strings.LastIndex(screen, "› Seed")
if !(match < ask && ask < start && start < box) {
t.Fatalf("search, ask, new conversation and box are out of order:\n%s", screen)
}
if width > 44 {
r.keys("Up")
r.waitFor(10*time.Second, "enter asks this here")
r.keys("Down")
r.waitFor(10*time.Second, "enter starts a new conversation")
}
t.Logf("restored choices at %d columns:\n%s", width, screen)

// Clearing the draft and closing Home are separate Escape presses.
r.keys("Escape")
r.waitFor(10*time.Second, say(t, "placeRestWord"))
r.keys("Escape")
r.waitFor(10*time.Second, say(t, "homeDoorWord"))
r.keys("Escape")
if screen := r.capture(); strings.Contains(screen, say(t, "placeRestWord")) {
t.Fatalf("Escape reopened Home:\n%s", screen)
}
r.keys("Space", "Space")
r.waitFor(10*time.Second, say(t, "placeRestWord"))
r.lit("/ask")
screen = r.waitFor(10*time.Second, "/task")
if strings.Contains(screen, "ask here on home") {
t.Fatalf("the removed /ask command is still offered:\n%s", screen)
}
})
}
}

// Both submission rows cross the real surface-to-engine boundary, with a local
// endpoint providing a fixed answer so no provider account is needed.
func TestHomeRestoredSubmissionDoorsWithStub(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("no tmux on PATH")
}
stub := &stopStub{kind: parkOnStream}
base := serveStopStub(t, stub)
stub.stop()
home := newHome(t, map[string]any{"model.talk": "stub/bounded"})
seedProject(t, home, "alpha", 0, time.Minute)
ws := newWorkspace(t, "submission", false)
r := startWithEnv(t,
[]string{"OPENROUTER_API_KEY=stub-key", "CODEAF_BASE_URL=" + base, "CODEAF_PROFILE_DIR="},
"home_submission", home, ws, 180, 40, "chat", "--no-host", "--one-model")
r.skipSetup(t)
r.waitFor(20*time.Second, say(t, "placeRestWord"))
r.lit("answer in the home pane")
r.waitFor(10*time.Second, say(t, "homeStartWord"))
r.keys("Up")
r.waitFor(10*time.Second, "enter asks this here")
r.keys("Enter")
pane := r.waitFor(20*time.Second, stopStubDone, say(t, "exchangeBack"))
t.Logf("ask here received the endpoint's answer in its own pane:\n%s", pane)
r.keys("Escape")
r.waitFor(10*time.Second, say(t, "placeRestWord"))
r.lit("answer in a new conversation")
r.waitFor(10*time.Second, say(t, "homeStartWord"))
r.keys("Enter")
conversation := r.waitFor(20*time.Second, stopStubDone, "idle", "› answer in a new conversation")
if strings.Contains(conversation, say(t, "exchangeBack")) {
t.Fatalf("the new-conversation row left the answer in an ask pane:\n%s", conversation)
}
t.Logf("the default row opened a conversation and received the answer:\n%s", conversation)
r.keys("Space", "Space")
r.waitFor(10*time.Second, say(t, "placeRestWord"))
}
6 changes: 3 additions & 3 deletions internal/e2e/questions_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1033,12 +1033,12 @@ func questionsWithdrawn(t *testing.T) {
awaitQuestion(t, r, "overwrite the checkpoint?", keyedWord("1", "overwrite it"))
shot(t, r, "raised")

// Escape folds the question to the chip; Ctrl+C interrupts the turn
// explicitly, which withdraws the question
// `esc` twice: the first folds the question to the chip (it is LATER, not
// cancel), the second is the surface's own interrupt, which takes the turn
// the question was holding open — and with the turn gone the question has
// stopped needing an answer.
press(t, r, "Escape")
press(t, r, "C-c")
press(t, r, "Escape")
gone := r.waitFor(90*time.Second, say(t, "questionWithdrawnWord"))
screenSays(t, gone, say(t, "questionWithdrawnMark"), "the withdrawn mark")
screenSays(t, gone, "overwrite the checkpoint?", "the withdrawn line names the question that went away")
Expand Down
91 changes: 67 additions & 24 deletions internal/e2e/stopbound_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,34 +310,35 @@ func runRealModelBoundedStop(t *testing.T) {
})
rig := start(t, "stopreal", home, workspace, 120, 40)

rig.lit("use the read tool on " + pipe + " and tell me what is in it")
// Explain the deliberate wait: a model otherwise correctly refuses to
// open a FIFO, so the test never reaches the stop it is supposed to time.
rig.lit("This is a controlled cancellation test. Use the read tool directly on " + pipe +
". It is an intentional FIFO with no writer. Blocking is expected: I will press Escape to cancel it. Do not inspect it with bash or avoid the read; start that read now.")
rig.keys("Enter")

// THE TURN HAS TO BE GENUINELY INSIDE THE CALL BEFORE THE KEY IS PRESSED.
// Waiting for the word `read` is not enough — it is in the sentence the person
// typed, so it is on screen before the model has decided anything, and a run
// that pressed esc there would be timing the stop of a turn that was between
// steps. The status line's own phase clause is the honest evidence: it reads
// `running read · Ns` only while the call is executing (internal/tui3's
// phase segment), and a call parked on a pipe with no writer never leaves it.
rig.waitFor(modelPatience, "running read")
// And it is STILL there several seconds later, which is what tells a call that
// is stuck apart from one that is merely slow.
// The journal identifies the exact read and its completion. The live
// footer can summarize it as "working" after briefly saying "running read".
deadline := time.Now().Add(modelPatience)
for !pendingPipeRead(t, home, pipe) {
if time.Now().After(deadline) {
t.Fatalf("the model never started the requested pipe read:\n%s", rig.capture())
}
time.Sleep(pollEvery)
}
time.Sleep(6 * time.Second)
screen := rig.capture()
if !strings.Contains(screen, "running read") {
t.Fatalf("the read call came back, so this run is not about an uncancellable wait:\n%s", screen)
if !pendingPipeRead(t, home, pipe) {
t.Fatalf("the read completed, so this run is not about an uncancellable wait:\n%s", rig.capture())
}

pressed := time.Now()
rig.keys("C-c")
rig.keys("Escape")

stopping := rig.waitFor(10*time.Second, say(t, "stopDetachWord"))
t.Logf("=== REAL MODEL: pane after ctrl+c (the bound, stated) ===\n%s", stopping)
t.Logf("=== REAL MODEL: pane after esc (the bound, stated) ===\n%s", stopping)

detached := rig.waitFor(stopBoundPatience, say(t, "stopDetachedWord"))
took := time.Since(pressed)
t.Logf("=== REAL MODEL: pane after the detach (%s after ctrl+c) ===\n%s", took.Round(time.Second), detached)
t.Logf("=== REAL MODEL: pane after the detach (%s after esc) ===\n%s", took.Round(time.Second), detached)
if took > stopBoundPatience {
t.Fatalf("the turn took %s to detach, which is past the bound", took)
}
Expand All @@ -354,6 +355,47 @@ func runRealModelBoundedStop(t *testing.T) {
rig.waitFor(modelPatience, "ready")
}

// pendingPipeRead checks the journal rather than a transient status label, so
// the path in the user's prompt cannot satisfy the wait.
func pendingPipeRead(t *testing.T, home, pipe string) bool {
t.Helper()
for _, transcript := range sessionTranscripts(t, home) {
pending := map[string]bool{}
for _, line := range strings.Split(transcript, "\n") {
var entry struct {
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"toolCalls"`
Took struct {
CallID string `json:"callId"`
} `json:"took"`
ToolCallID string `json:"toolCallId"`
}
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
for _, call := range entry.ToolCalls {
var args struct {
Path string `json:"path"`
}
if call.Function.Name == "read" && json.Unmarshal([]byte(call.Function.Arguments), &args) == nil && args.Path == pipe {
pending[call.ID] = true
}
}
delete(pending, entry.Took.CallID)
delete(pending, entry.ToolCallID)
}
if len(pending) > 0 {
return true
}
}
return false
}

// runStoppedInTime is the scenario where the engine DOES let go: the surface's
// wait ends inside the bound, nothing is detached, and the box is usable again.
func runStoppedInTime(t *testing.T, stub *stopStub, name, ask string) {
Expand All @@ -364,14 +406,14 @@ func runStoppedInTime(t *testing.T, stub *stopStub, name, ask string) {
waitUntilParked(t, rig, stub)

pressed := time.Now()
rig.keys("C-c")
rig.keys("Escape")

// THE SURFACE'S WAIT ENDS INSIDE THE BOUND. `interrupted` is the word the
// status line takes once the turn is genuinely over (internal/tui3's
// render.go), so waiting for it is waiting for the stream to have closed.
settled := rig.waitFor(stopBoundPatience, say(t, "interruptedWord"))
took := time.Since(pressed)
t.Logf("=== pane %s after ctrl+c: the never-ending stream is over ===\n%s", took.Round(time.Second), settled)
t.Logf("=== pane %s after esc: the never-ending stream is over ===\n%s", took.Round(time.Second), settled)
if took > stopGraceE2E {
t.Fatalf("the stream took %s to end, which is past the bound", took)
}
Expand Down Expand Up @@ -425,19 +467,19 @@ func runBoundedStop(t *testing.T, stub *stopStub, name, ask string) {
waitUntilParked(t, rig, stub)

pressed := time.Now()
rig.keys("C-c")
rig.keys("Escape")

// 1. THE BOUND IS ON THE SCREEN BEFORE IT FIRES.
stopping := rig.waitFor(10*time.Second, say(t, "stopDetachWord"))
if !strings.Contains(stopping, say(t, "stoppingWord")) {
t.Fatalf("the countdown is drawn without the word it belongs to:\n%s", stopping)
}
t.Logf("=== pane after ctrl+c (the bound, stated) ===\n%s", stopping)
t.Logf("=== pane after esc (the bound, stated) ===\n%s", stopping)

// 2. AND IT FIRES INSIDE THE BOUND.
detached := rig.waitFor(stopBoundPatience, say(t, "stopDetachedWord"))
took := time.Since(pressed)
t.Logf("=== pane after the detach (%s after ctrl+c) ===\n%s", took.Round(time.Second), detached)
t.Logf("=== pane after the detach (%s after esc) ===\n%s", took.Round(time.Second), detached)
if took > stopBoundPatience {
t.Fatalf("the turn took %s to detach, which is past the bound", took)
}
Expand Down Expand Up @@ -465,8 +507,9 @@ func waitUntilParked(t *testing.T, rig *rig, stub *stopStub) {
for time.Now().Before(deadline) {
switch stub.kind {
case parkOnStream:
// The second request is out and its text is arriving.
if strings.Contains(rig.capture(), stopStubStreaming) {
// The live summary can omit the sentence-ending period. The same
// words still prove that the second request is streaming on screen.
if strings.Contains(rig.capture(), strings.TrimSuffix(stopStubStreaming, ".")) {
return
}
case parkOnPipe:
Expand Down
25 changes: 25 additions & 0 deletions internal/e2e/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -559,7 +561,30 @@ func (r *rig) kill() {
return
}
r.dead = true
// Killing the tmux session sends a hangup but does not wait for codeaf.
// Its final writes must finish before testing removes the fixture home.
raw, _ := exec.Command("tmux", "display-message", "-p", "-t", r.name, "#{pane_pid}").Output()
pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
_ = exec.Command("tmux", "kill-session", "-t", r.name).Run()
if pid <= 0 {
return
}
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); {
if syscall.Kill(pid, 0) != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
// A failed scenario may have left an intentional uninterruptible tool
// wait. This PID belongs to the test's own pane, never to another rig.
_ = syscall.Kill(pid, syscall.SIGKILL)
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
if syscall.Kill(pid, 0) != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
r.t.Errorf("the test terminal process %d did not exit before cleanup", pid)
}

// dump is the transcript this suite owes anybody reading a failure: the screen,
Expand Down
Loading
Loading