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
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1388-restore-home-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
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."
---

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.
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
52 changes: 45 additions & 7 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 @@ -263,21 +265,30 @@ func (r *rig) skipSetup(t *testing.T) {
// setupIsUp reports whether the first-run flow is on the frame right now.
func (r *rig) setupIsUp() bool {
screen := r.capture()
return strings.Contains(screen, setupSkipKeysWord) || strings.Contains(screen, setupTitleWord)
return strings.Contains(screen, setupSkipKeysWord) || strings.Contains(screen, setupTitleWord) ||
strings.Contains(screen, setupMovesWord)
}

// setupPatience is how long [rig.skipSetup] waits for the flow to draw before
// deciding this machine is not going to show one.
const setupPatience = 8 * time.Second

// The two sentences that say the first-run flow is up. They are the SUITE'S OWN
// copies of internal/tui3's [setupSkipKeysWord] and the setup title, and they are
// spelled here rather than reached through [say] because tuiwords_test.go's own
// gate reads this file and every other one for the names it hands out — a door
// used by [start] itself has to stand before any scenario asks for a word.
// The sentences that say the first-run flow is up. They are the SUITE'S OWN
// copies of internal/tui3's [setupSkipKeysWord], the setup title and the form's
// legend, and they are spelled here rather than reached through [say] because
// tuiwords_test.go's own gate reads this file and every other one for the names
// it hands out — a door used by [start] itself has to stand before any scenario
// asks for a word.
//
// THE LEGEND'S SECOND CLAUSE IS HERE FOR NARROW FRAMES. Below sixty columns the
// form draws no title and its legend keeps only `enter goes on · tab moves`
// (internal/tui3's onboarding.go, [app.setupControlsKeys]), so a rig started at
// forty-four columns saw neither of the other two words, decided there was no
// setup, and left its scenario typing into the daily-limit field.
const (
setupSkipKeysWord = "esc skips setup"
setupTitleWord = "setting up"
setupMovesWord = "tab moves"
)

// keylessEnv is every variable a fresh-install run must not inherit: the two the
Expand Down Expand Up @@ -394,8 +405,12 @@ func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows
// forty-five seconds while looking at a perfectly live one. `needs you` is
// drawn on every desktop home, whatever it holds: an empty panel keeps its
// heading.
//
// AND THE SETUP FORM IS ONE AT EVERY WIDTH. Below sixty columns it draws
// neither its title nor `esc skips setup`, only the first two clauses of its
// legend, which is what [setupMovesWord] reads.
if hit, _ := r.waitForAny(45*time.Second, say(t, "placeRestWord"),
say(t, "starterTaskWord"), say(t, "setupTitleWord"), say(t, "setupSkipWord"),
say(t, "starterTaskWord"), say(t, "setupTitleWord"), say(t, "setupSkipWord"), setupMovesWord,
say(t, "landingKeysWord"), say(t, "welcomeStarterKeysWord"),
say(t, "answersAllowOnce"), say(t, "homeAnswerHint"), say(t, "homeNeedsHeading")); hit == "" {
t.Fatal("the terminal never reached an interactive surface")
Expand Down Expand Up @@ -559,7 +574,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