diff --git a/docs/changes/unreleased/1388-restore-home-controls.md b/docs/changes/unreleased/1388-restore-home-controls.md new file mode 100644 index 000000000..0f02d7d6c --- /dev/null +++ b/docs/changes/unreleased/1388-restore-home-controls.md @@ -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. diff --git a/internal/e2e/home_restore_e2e_test.go b/internal/e2e/home_restore_e2e_test.go new file mode 100644 index 000000000..9440d2db0 --- /dev/null +++ b/internal/e2e/home_restore_e2e_test.go @@ -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")) +} diff --git a/internal/e2e/questions_e2e_test.go b/internal/e2e/questions_e2e_test.go index bfde7dda0..996a656d6 100644 --- a/internal/e2e/questions_e2e_test.go +++ b/internal/e2e/questions_e2e_test.go @@ -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") diff --git a/internal/e2e/stopbound_e2e_test.go b/internal/e2e/stopbound_e2e_test.go index 50fd47849..731e41f2b 100644 --- a/internal/e2e/stopbound_e2e_test.go +++ b/internal/e2e/stopbound_e2e_test.go @@ -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) } @@ -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) { @@ -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) } @@ -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) } @@ -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: diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index f19488923..83570970c 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -30,7 +30,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "syscall" "testing" "time" @@ -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, diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index dff5e3e59..ab5c3ccf8 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -96,7 +96,7 @@ const tuiShortRows = 14 func TestTUIE2E(t *testing.T) { requireTmuxAndKey(t) - t.Run("home_opens_on_launch_as_seven_panels", testHomeShape) + t.Run("home_opens_on_launch_with_recent_sessions", testHomeShape) t.Run("a_real_conversation_on_the_panels_and_its_search_card", testRealConversation) t.Run("ask_here_end_to_end", testAskHere) t.Run("the_firing_reaches_the_person", testFiringReachesThePerson) @@ -327,16 +327,8 @@ func testRefusedLanding(t *testing.T) { // ── 1 ─────────────────────────────────────────────────────────────────────── -// testHomeShape opens the product with five projects on the machine and reads -// the shape home has TODAY (docs/design/home-mission-control/DESIGN.md): seven -// panels under a four-word bar, every seeded conversation on `threads`, -// an empty panel keeping its heading and its whisper, the foot's three verbs, and -// the two doors in and out of the screen. -// -// WHAT THIS SUBTEST USED TO ASSERT AND NO LONGER CAN. It read one flat ranked -// list with a `what wants you first` section line and a fold at its foot, and -// before that a tree of projects under an `─ elsewhere` rule. Both went: what a -// person has at a glance now is one panel per question, so that is what is read. +// testHomeShape reads Home's current sessions, projects, spend, activity and +// scheduled panels, then drives the command and double-space routes back to it. func testHomeShape(t *testing.T) { home := newHome(t, nil) for i, name := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} { @@ -348,20 +340,17 @@ func testHomeShape(t *testing.T) { screen := r.waitFor(20*time.Second, say(t, "placeRestWord"), say(t, "homePanelProjects")) t.Logf("home greeted on launch:\n%s", screen) - // EVERY PANEL IS ON THE PAGE. Forty rows is room for all seven at their + // EVERY PANEL IS ON THE PAGE. Forty rows is room for all five at their // floors in two columns, so a heading missing here is a panel the grid lost // rather than one a short frame squeezed out. - for _, name := range []string{"homeNeedsHeading", "homePanelProjects", + for _, name := range []string{"homePanelProjects", "homePanelRunning", "switcherSinceLeft", "homePanelSpend", "homePanelNext"} { if !strings.Contains(screen, say(t, name)) { t.Errorf("home has no %q panel:\n%s", say(t, name), screen) } } - // AN EMPTY PANEL WHISPERS. Nothing runs on a machine of seeded transcripts, - // so `running` keeps its heading and says what arrives there — never that it - // is empty. - if !strings.Contains(screen, say(t, "homeRunningWhisper")) { - t.Errorf("the empty `running` panel does not whisper %q:\n%s", say(t, "homeRunningWhisper"), screen) + if strings.Contains(screen, say(t, "homeRunningWhisper")) { + t.Errorf("populated sessions still show the empty-panel hint:\n%s", screen) } // THE BAR IS FOUR WORDS. Standing, memory and search are places reached by // command and by alt+5…7, and a bar that still named them is the seven-word @@ -371,10 +360,10 @@ func testHomeShape(t *testing.T) { t.Errorf("the tab bar reads %q, want %q:\n%s", got, want, screen) } - // Saved history is searchable but is not an open tab on this launch. + // Home includes recent saved conversations, even before a tab opens them. for _, title := range []string{"Seed Alpha", "Seed Beta", "Seed Gamma", "Seed Delta", "Seed Epsilon"} { - if strings.Contains(screen, title) { - t.Errorf("unopened history appeared as a tab: %q", title) + if !strings.Contains(screen, title) { + t.Errorf("recent history is missing from Home: %q", title) } } // Home keeps the command door but omits the ordinary navigation hints. @@ -406,19 +395,27 @@ func testHomeShape(t *testing.T) { } t.Logf("padding row above the foot rule (row %d) is blank", foot-1) - // Open the selected conversation, then return with Escape. Home is the - // final destination even after repeated presses. - r.keys("Down", "Enter") - r.waitFor(15*time.Second, say(t, "homeDoorWord"), say(t, "microcopy")) - r.keys("Space", "Space") - spaces := r.waitFor(15*time.Second, say(t, "homeDoorWord")) - if strings.Contains(spaces, say(t, "placeRestWord")) { - t.Fatalf("two spaces navigated instead of typing:\n%s", spaces) + // esc closes home into the conversation the launch loaded, and the rule over + // that conversation's box names both doors back. + r.keys("Escape") + closed := r.waitFor(15*time.Second, say(t, "homeDoorWord"), say(t, "microcopy")) + t.Logf("esc closed home into the conversation the launch loaded:\n%s", closed) + if strings.Contains(closed, say(t, "placeRestWord")) { + t.Errorf("esc did not close home:\n%s", closed) } - r.keys("Escape", "Escape", "Escape") + r.lit("/home") + time.Sleep(700 * time.Millisecond) + r.keys("Enter") back := r.waitFor(15*time.Second, say(t, "placeRestWord"), "Seed Alpha") - t.Logf("Escape settled on Home:\n%s", back) + t.Logf("/home reopened it:\n%s", back) + // And two spaces on an empty box is the other door. + r.keys("Escape") + time.Sleep(1200 * time.Millisecond) + r.keys("Space") + r.keys("Space") + gesture := r.waitFor(15*time.Second, say(t, "placeRestWord")) + t.Logf("space space opened home:\n%s", gesture) } // ── 2 ─────────────────────────────────────────────────────────────────────── @@ -566,13 +563,19 @@ func testAskHere(t *testing.T) { r.keys("Enter") r.waitFor(20*time.Second, say(t, "placeRestWord")) - r.lit("/ask remind me in 1 minute to drink water") + r.lit("remind me in 1 minute to drink water") time.Sleep(700 * time.Millisecond) typed := r.capture() - if strings.Contains(typed, "? ask here:") || strings.Contains(typed, "+ start a new conversation:") { - t.Errorf("submission action rows remain above the composer:\n%s", typed) + if !strings.Contains(typed, say(t, "homeAskHereWord")+": ") || + !strings.Contains(typed, say(t, "homeStartWord")+": ") { + t.Errorf("the two action rows are not both drawn while something is typed:\n%s", typed) } - r.keys("Enter") + t.Logf("the action rows while typing:\n%s", typed) + + // ctrl+enter, sent as the CSI 13;5u a kitty-protocol terminal sends. It + // hands the keyboard straight to the pane, and a pane holding the keyboard + // always names both ways back out of it. + r.ctrlEnter() pane := r.waitFor(25*time.Second, say(t, "homeAskHereWord"), say(t, "exchangeBack")) t.Logf("the exchange took the screen:\n%s", pane) @@ -612,7 +615,7 @@ func testAskHere(t *testing.T) { // card, which is the case asking-from-home.md states outright: closing home // does not touch it, and neither does opening another conversation. The // keyboard is already on the list, so ONE esc closes home. - r.keys("C-t") // leave Home through the new-conversation page + r.keys("Escape") // home closes into the conversation underneath time.Sleep(2500 * time.Millisecond) r.lit("/home") time.Sleep(700 * time.Millisecond) @@ -627,7 +630,8 @@ func testAskHere(t *testing.T) { // IS THE ORACLE for where the cursor is standing: the switcher's rows carry // no `›` lead of their own, and the one line that changes with the cursor is // the hint (internal/tui3's homeHint). - if !walkTo(r, say(t, "homeAnswerHint"), "Up") { + // The exchange follows the conversation row selected when Home opens. + if !walkTo(r, say(t, "homeAnswerHint"), "Down") { t.Fatalf("could not put the cursor back on the exchange row:\n%s", r.capture()) } r.keys("Enter") @@ -913,8 +917,8 @@ func testFiringReachesThePerson(t *testing.T) { // ── the second half: nobody is here when it fires ── // // A firing wakes the conversation it lands in, so the model may still be - // answering it. Ctrl+C stops the turn before the next errand. - r.keys("C-c") + // answering it. esc ends whatever is in flight before the next errand. + r.keys("Escape") time.Sleep(2 * time.Second) openHome(t, r) standReminder(t, r, "remind me in 1 minute to stretch") @@ -1109,7 +1113,7 @@ func testAnswerFromHome(t *testing.T) { // and the clock, because the panels are the counts; in a conversation it keeps // `1 want you` (DESIGN §1 law 11), read on the chat's own ten-second beat. So // B steps into its own conversation, reads its head, and comes back. - b.keys("Down", "Enter") + b.keys("Escape") inChat := b.waitFor(30*time.Second, say(t, "homeDoorWord"), say(t, "pulseWantWord")) t.Logf("window B's own conversation counts the question on its top line:\n%s", firstMatch(inChat, say(t, "pulseWantWord"))) openHome(t, b) @@ -1172,7 +1176,7 @@ func testHover(t *testing.T) { // The seeds share a word, so typing it lists all three; the cursor rests on // the action row, whose card is empty because that chat does not exist yet. r.lit("Seed") - screen := r.waitFor(15*time.Second, "› Seed", "Seed Beta") + screen := r.waitFor(15*time.Second, say(t, "homeStartWord"), "Seed Beta") rows := r.lines() target := -1 for i, line := range rows { @@ -1228,7 +1232,7 @@ func testFold(t *testing.T) { // AND TYPING SEES STRAIGHT THROUGH IT: a search matches every conversation on // the machine, including the ones no panel is drawing. r.lit("Seed T") - found := r.waitFor(15*time.Second, "› Seed T") + found := r.waitFor(15*time.Second, say(t, "homeStartWord")) deadline := time.Now().Add(15 * time.Second) for matchRow(found, "Seed T") == "" && time.Now().Before(deadline) { time.Sleep(500 * time.Millisecond) @@ -1239,7 +1243,7 @@ func testFold(t *testing.T) { } else { t.Logf("typing found the row behind the fold: %q", row) } - r.keys("C-u") + r.keys("Escape") time.Sleep(1500 * time.Millisecond) back := r.capture() if strings.Contains(back, "Seed T") || !strings.Contains(back, say(t, "placeRestWord")) { @@ -1547,7 +1551,7 @@ func testOneSpendFigure(t *testing.T) { } else { t.Logf("FINDING: the live strip was never caught — the turn may have finished first") } - r.keys("C-c") + r.keys("Escape") // The ledger's writer is a background goroutine and both places read the // file on a three-second beat, so the reading is taken after one beat has // certainly turned rather than in the same instant as the keystroke. diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 4a200a2f6..f6fe793c5 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -146,7 +146,7 @@ var tuiWords = map[string]tuiWord{ why: "the promise in home's box with nothing typed into it — the one box on a place", }, "homeDoorWord": { - screen: "esc back", + screen: "space space home", why: "the gesture back to home, named on the conversation's own rule", }, "microcopy": { @@ -183,7 +183,7 @@ var tuiWords = map[string]tuiWord{ why: "the day and the fortnight on home, and the third word of the four-place bar", }, "homePanelNext": { - screen: "scheduled", + screen: "standing", why: "every standing order this machine will act on — reminders, routines, watches, rules — soonest first", }, "homeRunningWhisper": { @@ -223,11 +223,17 @@ var tuiWords = map[string]tuiWord{ screen: "last active ", why: "the facts line on the card beside a search — the one card left once the resting card went", }, + "homeStartWord": { + screen: "start a new conversation", + why: "the action row under anything typed at home", + }, // ── asking from home ───────────────────────────────────────────────────── "homeAskHereWord": { screen: "ask here", - why: "the heading of the home pane opened by /ask", + why: "what one ↑ off the action row starts, drawn as the row's own heading over the pane. " + + "`ctrl+enter` is still bound and is no longer advertised — most terminals cannot send it " + + "and `alt+enter` belongs to the task layer (home.go's [app.homeHintWords])", }, "notifyAskWord": { screen: "waiting on you", @@ -516,7 +522,7 @@ var tuiWords = map[string]tuiWord{ "words home's own row draws, so this gate holds the spelling without a second copy of it here", }, "landingKeysWord": { - screen: "esc back · ctrl+c interrupts or quits", + screen: "esc interrupts · ctrl+c quits", why: "the notice a conversation greets on, and what a window that RESUMED an earlier one draws instead of home", }, "questionWaitingWord": { diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md index 603774489..54ca91ea7 100644 --- a/internal/manual/chat/asking-from-home.md +++ b/internal/manual/chat/asking-from-home.md @@ -2,23 +2,18 @@ ## Can I set a reminder from home -Yes. Type `/ask remind me at 6 to leave` on Home and press Enter. Choosing `/ask` -from the command menu writes `/ask `, ready for your question, like `/task`. A bare -`/ask` waits for your words. An inline `/ask` tag works too. +Yes. Type it on the home screen, press `↑` once — which lands on the row spelled +`ask here: "…"` — and press `enter`. ``` + ? ask here: "remind me at 6 to leave" + + start a new conversation: "remind me at 6 to leave" ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf - › /ask remind me at 6 to leave - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + › remind me at 6 to leave + enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear ``` -Plain text followed by Enter starts a new conversation by default. Only search results -appear above the seam; the old ask/new action rows and their footer hints are gone. -One Up selects the best result. Down past the last result returns to composing. -Removing `/ask` returns to ordinary submission. From a conversation, `/ask` opens -Home and asks there. A refusal leaves the question editable. - -What you get is **a row at the top of home's `threads` panel and a pane holding the exchange**. The row +What you get is **a row in home's conversation list and a pane holding the exchange**. The row stays there — with what the errand is doing written in its tail — until the errand is finished and you have read what it came to. The pane is the exchange itself: what you said, the reply as it streams, one line per tool call, and the card when one arrives. On an @@ -51,7 +46,7 @@ done**. They sit at the very **top of home's `threads` panel**, above every conversation — an errand is a thing you asked for a minute ago. `enter` or `→` on the row hands the keyboard to the pane. The hint under the box says so: -`↑↓ move · enter or tab answer this ask here`. (`tab` on the row was the way in +`↑↓ move · enter or tab answer this ask here · esc close`. (`tab` on the row was the way in until the places arrived and took that key for the next place; `→` points at the column the pane is drawn in, which is where the gesture went.) @@ -184,8 +179,8 @@ it: its own keys, which is why one half of the toggle stayed and the other moved to the arrow that points at the pane.) It works from `continue as a conversation` row, an open card. It never loses what is in the pane. -- **`esc`** in the pane hands the keyboard to the list, preserving any half-written - follow-up for when you return. +- **`esc`** in the pane hands the keyboard to the list. One layer at a time: if you have + half a follow-up typed, the first `esc` clears that and the second one leaves. - **`enter`** on the exchange's row in the list hands the keyboard to its pane. - **clicking** puts the keyboard where the pointer is. A click on a list row opens that row, as `enter` would, *and* takes the keyboard to the column; a click anywhere in the @@ -299,7 +294,7 @@ created until you answer it: keyboard goes back to the list. **This is the only way to say no in this pane**: `esc` here hands the keyboard back to the list without answering anything, and a card left standing on the column is not an answer. It is the same key on home's answer row and in a conversation, - where `esc` also defers; `0` explicitly declines. + where `esc` also declines. Those four answers are the only four, and a card draws three of them where `3` is not one it can offer. Each answer is a row of its own and **a click anywhere along it takes that diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7a862cdc7..08e08d763 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -22,7 +22,7 @@ A filter with no matches shows `no commands match` and keeps unrelated results h The list follows the caret as well as edits. The box remains editable while it is open. A command chosen inside a sentence completes its token rather than running on its own; -send tags such as `/ask` and `/task` retain their submission behavior. +The `/task` send tag retains its submission behavior. On home, command rows describe what they will do there, including commands that open a conversation first. See *What each command does on home*. In a conversation, pointer @@ -170,7 +170,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/land` | — | — | says what has been changed for a folder you chose and is waiting to go into it | | `/land` | — | `now` | …puts it in: a branch merged for a repository, files copied back for a plain folder | | `/land` | — | `` | …when more than one folder is waiting; `/land now` puts that one in | -| `/rewind` | `/undo`, `/back` | — | opens the rewind timeline — the whole conversation as a list (Escape backs out without rewinding) | +| `/rewind` | `/undo`, `/back` | — | opens the rewind timeline — the whole conversation as a list (esc esc is the quick inline version) | | `/permissions` | `/perms` | — | lists what runs without asking; `d` drops a line | | `/standing` | `/orders` | `` | makes those words a standing order — a card to answer, never work done once | | `/standing` | `/orders` | — | what stands over this conversation; `p` pauses, `s` stops, `n` excepts this place | @@ -239,7 +239,7 @@ drawing, and the words are the same. **One gesture, one spelling.** Wherever the sheet names the escape key it writes `esc back` — the places row, the task roster on `alt+t`, the conversation switcher on `alt+k`, -`esc` — and that is the same two words the cards, pickers, the rewind sheet and the +`space space` — and that is the same two words the cards, pickers, the rewind sheet and the switcher's own strip already use. The sheet used to say `esc comes back`, `esc goes back` and `esc leaves` on four different rows, which read as four gestures on the one screen you open to find out how many there are. The longer `esc leaves it as it was` is a different @@ -268,7 +268,7 @@ any filter box, picker or panel: those have the keyboard first, so the key never this binding. The key is named on the first row of `/help` itself, and on the line every session opens -with — `esc back · ctrl+c interrupts or quits · ? for help`. `/?` is also an alias of +with — `esc interrupts · ctrl+c quits · ? for help`. `/?` is also an alias of `/help`, and has been all along. `/quit` (or `/exit`, `/q`) **closes the conversation in front**, and it does it at once — @@ -368,8 +368,11 @@ maintained a little at a time by the reader that runs after each turn. ## /rewind — go back to an earlier point in the conversation `/rewind` (or `/undo`, `/back`) opens the **rewind timeline**: a fullscreen list of the -whole conversation, oldest first, that you pick a point out of. Escape is back navigation; -it no longer opens rewind. The command row reads `go back to an earlier point`. +whole conversation, oldest first, that you pick a point out of. It is the deliberate way +in. The quick way is esc esc, which draws a cut line through the transcript on screen +instead of opening anything — see the sessions and rewind page for both. + +The command row reads `go back to an earlier point · esc esc takes back the last`. On the timeline: ↑↓ move, typing searches, the first `enter` places the pick and the second `enter` on that same point does the rewind, `esc` clears the search and then @@ -1068,14 +1071,16 @@ conversation in them**, which is the one thing `/resume` cannot show you: `/resu "which conversation, here", and this is "what is there at all". **It is also what a bare `codeaf` opens on.** The conversation the launch picked is loaded -underneath; opening its row returns to it. Escape stays on Home. Home stays out of the way when you named a conversation +underneath, and `esc` — or `enter` on the row the cursor starts on, which is that same +conversation — drops into it. Home stays out of the way when you named a conversation (`--session`, `codeaf resume`), on a `--once` or `--host` run, and on a machine whose only conversation is the one already open. There is no welcome box when home greets you. Not -greeting you is not the same as being out of reach: `/home`, or `esc` from a conversation, opens it on a one-conversation machine and on an empty one alike, and over `--host` +greeting you is not the same as being out of reach: `/home`, or `space` twice on an empty +box, opens it on a one-conversation machine and on an empty one alike, and over `--host` it opens the far machine's. There is no argument form. There are three other ways in: **`alt+1`**, home being the first -of the four places on the tab bar; **`esc`** from a conversation; and **`tab`** from any +of the four places on the tab bar; **`space` twice** on an empty box; and **`tab`** from any other place. **It is seven panels**, in one column under 110 cells, two from 110 and three from 170, @@ -1083,16 +1088,17 @@ always in one order: an unheaded list of open tabs followed by up to three dimme closed conversations, `needs you` (every question waiting on you, a digit answers the top one from anywhere), `projects` (folders, read-only), `tasks` (the last day's tasks, running or landed, newest first), `since you left` (what landed while you were -away), `spend` (today and the fortnight) and `scheduled` (standing orders, soonest first). +away), `spend` (today and the fortnight) and `standing` (standing orders, soonest first). Which column a panel stands in follows what it holds: the panels with rows fill the **field** at the left, and the **rail** at the right holds `projects` and `spend` at its top with the quiet panels under them. An empty panel keeps its heading and one dim line naming what arrives there. -`↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` dismisses a local layer and otherwise stays on Home. **Typing does two things at once**: what you type is a new +`↑`/`↓` walk a column, `←`/`→` cross columns, `enter` opens, `esc` closes back into the +conversation you came from. **Typing does two things at once**: what you type is a new conversation waiting to be sent AND a live search over every project on the machine — the -panels give way to the matches, with none selected until you navigate into them. -Type-and-enter starts a chat. `/ask ` asks in a home pane instead. **A line that starts with `/` is +panels give way to the matches, with `start a new conversation: "…"` directly above the box +holding the cursor, so type-and-enter still starts a chat. **A line that starts with `/` is the third thing typing can be**: a command, run rather than sent (see *Typing a slash to see the command list*). The box says `› type to search or start something new` and the foot names the available draft controls: @@ -1118,8 +1124,8 @@ no conversation matches that folder is gone · ``` -`no conversation matches` is a search that found nothing; Enter still starts a new -conversation with your words. `/new is unavailable here` is what the typing-to-start box says where no +`no conversation matches` is a search that found nothing — the `start a new conversation` +row is still there. `/new is unavailable here` is what the typing-to-start box says where no fresh-session seam exists. The last is `enter` on a row whose folder has been deleted or moved since its last conversation: home stays up and nothing is opened. **How many conversations this terminal already holds is never a refusal.** Past twelve, a quiet @@ -2078,16 +2084,3 @@ you closed*, including what a terminal that cannot send the key does instead. `alt+k` is the other way back: it lists every conversation on this machine, closed tabs included, and opening a row brings the tab and its draft back too. - -## /ask — ask from home without opening a regular conversation - -Type `/ask ` and press Enter to ask in a home pane. Choosing `/ask` from the -command menu inserts `/ask ` and leaves the question for you to write, like `/task`. -Bare `/ask` waits for your question. An inline `/ask` tag in a sentence works too, and -is removed before sending. Multiple active submission tags keep the draft for correction. -From a conversation, `/ask` opens Home and uses the same ask pane. - -Plain text on Home starts a new conversation by default. Only search results appear -above the seam: one Up selects the best match, Enter opens a selected result, and Down -past the last result returns to composing. The old ask/new action rows and their -footer hints are absent. diff --git a/internal/manual/chat/compacting-over-and-over.md b/internal/manual/chat/compacting-over-and-over.md index 7c598106d..0a315897b 100644 --- a/internal/manual/chat/compacting-over-and-over.md +++ b/internal/manual/chat/compacting-over-and-over.md @@ -134,7 +134,7 @@ foldable material above the target. The pass still succeeds with what it took, a honestly, because there was nothing more to take. That is the one case where two passes in quick succession are not a defect. -## What happened to the earlier messages — where did the folded messages go — how do I get the compacted text back, why it loses the earlier part of our chat +## Where did the folded messages go — how do I get the compacted text back, why it loses the earlier part of our chat They are still on disk. A fold replaces the oldest assistant work in the model's window with one line such as diff --git a/internal/manual/chat/empty-screen.md b/internal/manual/chat/empty-screen.md index 5939c1c28..6591997bd 100644 --- a/internal/manual/chat/empty-screen.md +++ b/internal/manual/chat/empty-screen.md @@ -163,11 +163,11 @@ a row opens it too. On a short window the list is cut to the rows that fit. **When there are none, nothing is drawn** — no heading, no `no recent sessions` line, no rows held open. A fresh machine sees the wordmark, the model line, the box and the try line, and that is all. Every earlier conversation, in every project, is on the home -screen (`/home`, or Escape from the conversation) and in `/resume`. +screen (`/home`, or space twice on an empty box) and in `/resume`. -## The line about esc and ctrl+c — when does "esc back · ctrl+c interrupts or quits" appear +## The line about esc and ctrl+c — when does "esc interrupts · ctrl+c quits" appear -The empty screen carries no line about leaving. `esc back · ctrl+c interrupts or quits · ? +The empty screen carries no line about leaving. `esc interrupts · ctrl+c quits · ? for help` lands as the first dim line of the conversation the moment the greeting goes — your first keystroke — where it sits directly above the box you have just started typing into. A conversation that opens on a transcript, such as a resumed one, has it on its diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index a8a445247..e6c6e4ad0 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -4,11 +4,11 @@ The dim row under your message box — the last row of the frame — is the hint slot (until 2026-09-17 it was the right end of the rule above the box; the numbers have that end now). -Most of the time it names the keys that work right now — `ctrl+c interrupt` while an answer is coming, +Most of the time it names the keys that work right now — `esc interrupt` while an answer is coming, `y allow · n deny · a always` while codeaf is asking you something, `/ commands` when nothing is happening. Once you have used codeaf a little, that idle line sometimes carries a tip instead: one sentence naming a key or a command you have not used yet, and what it does. -For example `ctrl+. sees every task this project has run`, or `/rewind takes back an earlier +For example `ctrl+. sees every task this project has run`, or `esc esc takes back the last message`. A tip only appears over an empty box while nothing else is happening. The moment you type, @@ -41,8 +41,8 @@ that retires it. list by typing `/`. - `ctrl+. sees every task this project has run` — after the first task starts. Retired when you open the task page, by `ctrl+.` or `/history`. -- `/rewind takes back an earlier message` — after an answer of about 1,500 characters or more. - Retired the first time a rewind lands, from `/rewind`. +- `esc esc takes back the last message` — after an answer of about 1,500 characters or more. + Retired the first time a rewind lands, from `esc esc` or from `/rewind`. - `/compact summarizes the conversation now` — when the conversation passes half its context window. Retired when a `/compact` finishes. - `/files finds everything made for you` — after the first `/export` writes a file. Retired @@ -63,7 +63,7 @@ the cost tip over the task page tip — and the other waits its turn. Open the settings panel with `/settings` (or `ctrl+,`), go to the **Display** tab, and flip the **hints** row off. Enter or space toggles it. The change lands at the end of the next turn. Off silences the tips and the what's-new lines together; it does not touch the keys -the slot names for a live state — `ctrl+c interrupt` and the rest are not hints and cannot be +the slot names for a live state — `esc interrupt` and the rest are not hints and cannot be turned off. Turning the row back on shows whatever is due. Tips you had already retired stay retired. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index a41af8ecb..0bea1a34c 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -34,7 +34,7 @@ answering one question you would ask walking up to a colleague's desk: Spark Fleet Ssh Audit · 2 hosts up, 1 not made apartments-minto-street.md - scheduled + standing the 6am repo watch ``` @@ -44,8 +44,8 @@ most of those left-hand panels have nothing in them, and they gather under the p in the right-hand rail instead — heading and one dim line each — so the left of the screen is only ever the things that are actually going on. -Escape stays on Home once its local layers are dismissed. Open a conversation row -or use `alt+k` to return; drafts and work stay intact. The resting foot reads +`esc` puts you back in exactly the chat you came from, untouched — nothing was closed and +nothing was sent while you were looking. The resting foot reads `alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands`. The project, approvals and chats hints appear only where those controls can act. The controls stay the same as the cursor walks between rows. `→` opens the selected @@ -76,7 +76,7 @@ panels below them, which are in the rail only because they are quiet today. **The rank never moves, only the side.** Within the field and within the rail the order is always `sessions`, question rows, `projects`, `since you left`, `spend`, -`scheduled` — so two panels that both fill never swap places. +`standing` — so two panels that both fill never swap places. One column under 110 cells, where every panel is in that one order and there is no rail; two columns from 110; three from 170, where the rail is the third and the field fills the @@ -92,7 +92,7 @@ from home stands there as a card. Nothing else stands in it. | `sessions` | one of the fifteen most recent conversations | opens the conversation | `your recent conversations appear here` | | `since you left` | what landed while you were away | opens the record, the file or the place | `what watches and tasks did while the terminal was shut` | | `spend` | today, the fortnight, who it went to | nothing — its lines are read, never stood on or pressed; the heading opens the spend place | `every chat and task is priced here` | -| `scheduled` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | +| `standing` | a standing order — reminder, routine, watch or rule — soonest first | opens the standing place | `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` | **An empty headed panel keeps its heading and that one dim line** — it names what arrives there and the one thing that puts it there, and it never says the panel is empty. On a narrow column @@ -364,8 +364,9 @@ Home's open rows, the tab strip and the default chats menu immediately. It keeps running work and drafts. A waiting conversation carries a `?`; the question remains reachable even when its tab is closed. -`/ask` exchanges follow the conversation rows. Escape opens Home; Enter opens the -selected row. Further Escape presses stay on Home. +**On `space` `space` the cursor is already on the chat you were in before this one**, so a +switch back is two keys — `space` `space`, then `enter` — and `esc` still goes back to the +conversation behind home. ## Start a chat in another folder — the projects panel is read, not pressed; ctrl+t starts a chat elsewhere @@ -502,7 +503,7 @@ own tab stack — so going back is `enter`. A window that has held only one conv "before", and the cursor is on its own row in the conversation list, which says `here`. **On a launch** — home greeting you — the cursor is on the conversation this window is -holding, the row you can open to return. +holding, the row `esc` drops back into. **`↑` off the top row of home stays on it.** The tab bar — the row of four words — is reached by clicking a word, by `tab`, or by a place's own chord (`alt+2` and the rest); on @@ -531,8 +532,8 @@ A quiet morning on a busy machine is the same screen with fewer rows: `needs you `sessions` whispering, the conversation list full, `since you left` holding what fired overnight. There is no accent anywhere when nothing is waiting on you. -Typing works exactly as it does anywhere: Enter starts a conversation and `/ask` asks -in a home pane. Only search results appear above the seam. +Typing works exactly as it does anywhere: `? ask here: "…"` and `+ start a new +conversation: "…"` rise out of the box, and `enter` starts the conversation. Over `--host`, in the fraction of a second before the far machine answers, home draws no panels at all — a whisper over a server full of work would be untrue. @@ -548,17 +549,17 @@ folds it. One panel is open at a time; opening a second folds the first. An open taller than the window shows what fits and its line still counts the rest — `3 fewer · 40 more` — and names no place, because `enter` on it folds rather than opens. The way to those rows is the panel's **heading**: `sessions` opens sessions, `since you left` opens memory, -and `scheduled` opens standing. Conversations and extra question rows have no heading; +and `standing` opens standing. Conversations and extra question rows have no heading; `projects` opens nothing. The foot under a fold says which way it will go: `enter shows the rest`, then `enter folds them`. Opening lasts as long as the window; a relaunch starts folded. The fold wears no mark: home spends its two marks on the amber `?` and the one moving cell. **A tall terminal grows the panels**, once every panel has what it naturally shows: -additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `scheduled` from three to five. `spend` never grows. What is left over is air +additional question rows, `since you left` and `projects` to eight rows; `sessions` keeps at most fifteen recent conversations; `standing` from three to five. `spend` never grows. What is left over is air under the shorter column. -**A short terminal squeezes them in a fixed order**: `scheduled` gives way first, then `spend`, +**A short terminal squeezes them in a fixed order**: `standing` gives way first, then `spend`, then `since you left`, then `sessions`, then `projects`; the conversation list and additional question rows shrink last. A squeezed panel keeps its heading, the rows that fit and its `N more` line; only when every panel is down to that is a panel dropped — and the panels that are only @@ -577,7 +578,7 @@ panels, and `enter` again folds it. The box at the foot still searches every con the machine as you type — a project's name, a folder's name or a word from what a task came to all find them — whether or not a panel is drawing the row. -Every panel's fold works the same way: `N more` under `sessions`, `since you left`, `scheduled` +Every panel's fold works the same way: `N more` under `sessions`, `since you left`, `standing` and `projects` opens that panel. The places themselves — tasks, standing, spend — are on the tab bar and their slash commands, not behind the folds. @@ -628,24 +629,25 @@ cent — one reading of one file, wherever you are standing. words — `home tasks spend settings` — and `tab`, `alt+1` … `alt+4` walk them. Standing, memory and search open exactly as they did: -- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `scheduled` row; +- **`/standing`** (or `/orders`), `alt+5`, or `enter` on a `standing` row; - **`/memory`** (or `/memories`), `alt+6`, or `enter` on memory's line in `since you left`; - **`/search`**, `alt+7`, or the typed door on home's box. While you stand in one of the three, its word is drawn after the four so you can see where you are; `tab` from there goes to home. `alt+.` draws the map of all seven with their numbers. Home's own panels already summarise the three on the bar: `sessions` is a glimpse of -tasks, `spend` of spend, `scheduled` of standing. +tasks, `spend` of spend, `standing` of standing. ## Why did a dashboard open when I started codeaf — home greets you **Home is the first thing you see when you open codeaf.** The conversation your launch -would have opened is loaded and waiting underneath it: opening its row returns to it. In +would have opened is loaded and waiting underneath it: `esc` drops straight into it. In effect the launch is the launch you always had, with home already open on top of it. **The cursor starts on the conversation this window is holding** — its row in `where you were`, wearing `here` — so the first frame already answers "where am I". `↑` off the top of -the column stays there (see *Where the cursor starts*); opening a conversation row resumes it. +the column stays there (see *Where the cursor starts*); `esc` goes on with +what you were doing. Nothing about *which* conversation opens is changed by this. The door picks it exactly as it always did — this directory's most recently spoken-in chat, or a fresh one — before home is @@ -677,7 +679,7 @@ There is no setting for this and no flag to turn it off: whether home greets you from how you launched and what the machine holds, both of which answer themselves. Not being greeted is not the same as being out of reach. Once you are in a conversation, -`/home` — or `esc` from the conversation — opens the screen whenever you want it, on a +`/home` — or `space` twice on an empty box — opens the screen whenever you want it, on a machine with one conversation and on one with none (see *Why is the home screen empty*). ## Close or archive a conversation — put junk away and clean up home @@ -704,16 +706,19 @@ Home has **two shapes**. on the chat you were in before this one. **The moment you type a character it becomes one list, a drop-up.** It lifts so that its -best match lands nearest the message box. For ordinary text, only search results appear above the seam; -there are no submission action rows. One `↑` selects the strongest match, and each `↑` -past it walks into a weaker one. Clearing the box puts the panels back. +last row — the action row, `start a new conversation: "…"` — lands directly above the box +you are typing into, with `ask here: "…"` between it and the matches, and the matches rise +above the pair **best one first**: the strongest match is two `↑` away, and each `↑` past it +walks into a weaker one. Everything to do with typing is then one cluster at the foot: your +words, the row saying what `enter` will do with them, and the hint under it. Clearing the +box puts the panels back. **So the cursor does move between the two**, from up in the panels to the foot and back. One keystroke of re-anchoring is cheaper than a page of panels pinned against the box. **On a frame 136 columns or wider, a card stands beside the matches** — about the match under the cursor (*The card beside a search*). It never moves while the list lifts, and it -stays empty until you select a result. +goes empty on the `start a new conversation` row, which is a chat that does not exist yet. ## Switch between sessions — enter on home @@ -722,10 +727,11 @@ stays empty until you select a result. **One click is `enter`.** A click on a row opens it, and a click on a fold that names a place opens that place. A click on a panel's **heading** opens the place the heading names: `sessions` opens sessions, `since you left` opens memory, `spend` opens spend, and -`scheduled` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing +`standing` opens standing. There is no `needs you` heading. The conversation list has no heading. The `projects` heading opens nothing and stays dim. **A heading that opens somewhere underlines on mouse-over.** The pointer on a heading moves neither the cursor nor the marked heading. -A click on a `/` command only selects it; `enter` runs it. +A click on a `/` command, `ask here` or the new-conversation row only selects it; +`enter` runs it. `enter` opens the session under the cursor — **any row on the screen, in any project.** The chosen journal is opened and replayed, and **the conversation you were in stays open @@ -748,7 +754,7 @@ alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands promise moved on 2026-09-17: the lowest line is for keys) and it says what THAT row's keys do on a row that has its own — a `since you left` line, -a fold door — with the available draft controls before `esc`. +a fold door or the action row — with the available draft controls before `esc`. The foot omits the `ctrl+o` and `tab` hints; both keys still work. ## Typing a long question on home — does the box wrap, and where does a paste go @@ -777,9 +783,11 @@ opens the **composer layer**, where the three facts a task needs are settled — what, how much (the places page, *the composer layer*) — and a second `alt+enter` sends it off. -**`/ask ` asks here.** Enter sends the question to its own home pane. -Plain text plus Enter starts a new conversation. `ctrl+enter` remains an unadvertised -shortcut for asking here on terminals that can send it. +**`ask here` is one `↑` and then `enter`.** The row is already on the screen while you +type: `? ask here: "…"` sits directly above `+ start a new conversation: "…"`, and the +cursor rests on the lower of the two, so the ask is one keystroke up. `ctrl+enter` is still +bound to it and is no longer named on the foot, because only a terminal that can tell +`ctrl+enter` from a plain `enter` ever sends it. **A paste lands in home's box.** Paste while home is open and the text goes into the foot box — searching, exactly as typing does — or into the ask-here exchange's own box when that @@ -814,7 +822,7 @@ sentence in the other window's box arrives in yours. **`codeaf chat` in a folder whose conversation is open elsewhere** — you opened codeaf and it said `open in another window` — does not start a second one silently. It opens home with that row pointed at, so one `enter` continues where you -left off and typing and submitting a new message starts a conversation instead. A plain `codeaf` in a folder +left off and `esc` gets on with a new conversation instead. A plain `codeaf` in a folder whose engine is already holding a conversation simply **sits down in the one the engine has**. @@ -824,7 +832,7 @@ has**. cannot happen at all**: the row says `open in another window — go there, or start a new conversation here`. -## I pressed enter twice on the held row and it did not move — moving a conversation from a window with no engine asks first — the move card, enter moves nothing, the cursor starts on leave it there +## Moving a conversation from a window with no engine asks first — the move card, enter moves nothing, the cursor starts on leave it there This is the road a window takes when there is no engine holding the conversation — `--no-host`, `--debug`, a test. On the ordinary `codeaf chat` you will not meet it: see @@ -1121,8 +1129,8 @@ which beats a word inside it, which beats the letters appearing in order. Then t break ties: a conversation **waiting on you** beats a cold one it ties with, whatever their ages, and after that the more recent one wins. -`↑`/`↓` walk the matches, `enter` opens the highlighted one. Escape preserves the draft and stays on Home. Clear the box with `ctrl+u` to restore -the unfiltered panels. +`↑`/`↓` walk the matches, `enter` opens the highlighted one. `esc` clears the box and puts +the panels back; a second `esc` closes home. **The matches grow upward out of the box, best one first** — see *Why is the best search result at the bottom* below. On a frame 136 columns or wider the card beside them follows @@ -1131,7 +1139,8 @@ either. ## Why is the best search result at the bottom — the order of the matches -**The strongest match is directly above the seam, so one `↑` selects it.** Each `↑` past that walks into a +**The strongest match is the first conversation above the two typing rows — `ask here` and +`start a new conversation` — so two `↑` get you to it.** Each `↑` past that walks into a weaker match, and `↓` comes back down toward the box. That is upside-down next to an ordinary ranked list, and deliberately so. A list you read @@ -1156,8 +1165,10 @@ keystroke with nothing loaded and no model called. Two things cover what a meaning-search would have been for: -- **Enter starts a new conversation by default.** Even when the search finds nothing, - your words can become the first message of a new chat. +- **The row that offers to start a conversation never goes away.** A query that matches + nothing still reads `start a new conversation: "…"` above the box, so the worst case of a + search that missed is that your words become the first message of a new chat — which is + very often what you wanted. - **Ask the chat instead.** It has a `tasks` tool over the whole project record and you can ask it in sentences: *"what was that thing where we fixed the flaky auth test?"* Home is the fast layer; the conversation is the thoughtful one. @@ -1168,19 +1179,26 @@ Whatever you type is **three things at the same moment**: a new conversation wai sent, a live query over the machine, and — if it starts with `/` — a command. You do not choose between them before you start typing. -**Only search results appear above the seam.** The best result is nearest the box. -No result is selected while you compose, so Enter starts a new conversation and sends -your words. Use `/ask ` to ask in a home pane instead. +**Everything about typing sits together at the bottom of the screen.** The moment you type +a character the panels give way to a drop-up: the matches rise from the foot, and the +**last** row of the list is the action row — `start a new conversation: "…"` with your words +quoted back — sitting directly above the box you are typing into. ``` alpha ○ Pricing Sheet Import 2h - ○ Pricing 12m ← one ↑ + ○ Pricing 12m ← two ↑ + + ? ask here: "pricing" + + start a new conversation: "pricing" ─ glm-5.3-flash:auto · ◇ asks ─── project: ~/codeaf › pricing - alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands + enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear ``` +**The cursor rests on the action row by default.** So typing and pressing `enter` starts a +fresh conversation and sends what you typed, however many matches are on screen. + **Where it opens is on the rule above the box, and `enter` honours it.** That line reads `glm-5.3-flash:auto · ◇ asks ─── project: ~/src/parser`: the model, effort and approvals start at the left, with `project: ` at the far right naming where the conversation will open. @@ -1190,8 +1208,11 @@ project's row and the rule re-points — and with nothing under the cursor it is own project. `alt+p` pins it, `/model` pins the model, `alt+e` walks the rung and `alt+a` walks the gate; *Change the model before starting* has the whole of all four. -One `↑` selects the best result; Enter then opens that result. Walking `↓` past -the last result returns to composing. Neither submission mode adds a footer hint. +One `↑` steps off that row **up** onto `ask here: "…"`, which answers the same sentence in +the pane on the right instead of opening a conversation for it — see *Asking from home*. A +second `↑` reaches the best match. The hint under the box tracks which of the two `enter` +means: the line in the example above on the action row, and `enter open · ↓ back to +starting a new conversation · esc clear` once you are on a match. A line beginning with `/` is the third thing typing can be — a command, run rather than sent. See *Running a slash command from home*, directly below. @@ -1203,7 +1224,8 @@ anywhere**: on `new session failed: ` home closes and your words are put message box unsent — never delivered to the conversation this window was already holding. **Pasting a folder path into an empty home box offers one Enter to start there.** -The complete paste must name one existing local directory. If the next key is Enter, it opens a +The complete paste must name one existing local directory. The action row reads +`start a new conversation in `. If the next key is Enter, it opens a conversation there without sending the path as a message. Any other key — including space, an arrow, Backspace, a shortcut or Shift+Enter — cancels the offer and keeps normal editing behavior. A second paste also cancels it. The remaining text is an ordinary @@ -1221,7 +1243,10 @@ as commands. The folder notice may remain, but the path stays in the box. it.** Typing `/settings` on home and pressing `enter` opens the settings panel; it does not start a conversation whose first message is the word `/settings`. -A fully typed command runs on Enter without an extra action row or footer hint. +**The screen says which `enter` you are about to press, before you press it.** With a command +in the box the action row reads `+ run /settings` in place of `+ start a new conversation: +"…"`, and the foot under the box reads `enter runs this command · ↑ ask here · +↑↑ pick a match · esc clear`. **Every command has a FATE here, and the list says which before you press `enter`.** Each row of the `/` drop-up reads ` · ` — so `/compact` says @@ -1234,7 +1259,6 @@ mixed in. Keep typing to filter; the best name match is selected without changin order. ↑ / ↓ choose, PgUp / PgDown and the mouse wheel scroll, and Enter takes the row. A command that takes words leaves `/model ` in the box ready for its argument. Esc clears the home draft. Moving past the command word into its arguments restores ordinary search. -An inline `/ask` tag also asks the sentence here, just as `/task` marks work to send off. Other ordinary slash-command mentions remain prose. *Typing a slash to see the command list* describes the shared token and filtering rules. @@ -1242,8 +1266,9 @@ list* describes the shared token and filtering rules. `start a new conversation in ` only until the next key. Enter accepts; any other key returns it to ordinary message text. See *Start something new from home*. -**You can ask about a command instead of running it.** Type `/ask what does /settings do?` -and press Enter. The question goes to the pane; `/settings` is not executed. +**You can ask about a command instead of running it.** Type it and press `↑` then `enter` +— the `ask here` row — and the answer comes back in the pane on the right without the +command being run (see *Asking from home*). ## What each command does on home — the fate on every row of the / list @@ -1355,7 +1380,7 @@ standing choice. The selected project remains pinned. Neither cell is drawn on a **Home and conversations share the model, effort and approvals controls.** Other full-screen places have no general conversation message box; return Home -with Escape to start a conversation. +with `space` `space` (or `alt+1`) to start a conversation. **`/folder` is the third door onto the same pin, and it is the one that shows you the disk.** Typed on home — bare, or with a path after it — it opens the folder browser with the title @@ -1364,32 +1389,101 @@ Typed on home — bare, or with a path after it — it opens the folder browser already changed. `/place` and `/dir` are the same command. Nothing on that sheet touches the conversation behind home. -## How do I get back to the dashboard or the home screen from any page — Escape +## How do I get back to the dashboard or the home screen from any page — press space twice + +**From inside any conversation, press the space bar twice with an empty message box.** +That is the way back to home, and it lands with the cursor on the chat you were in before +this one. + +There is no `ctrl+` chord for it: every `ctrl+` this surface has is already taken, +and `esc` was not available either — on an idle conversation it already arms rewind and +already drops a message you parked. What was left is the one keystroke that reliably means +nothing: a message that starts with two spaces is a message nobody meant to send that way. + +**The first space types itself, plainly.** It is the *second* space, arriving to find a box +that still shows nothing with that space behind the cursor, that takes the whole draft away +and opens home. So a space you actually wanted is never eaten: space then `x` leaves ` x`. -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -Escape never starts rewind or stops a turn. `ctrl+c` interrupts a running turn and quits -when idle; `/rewind` opens the rewind timeline. +**Wherever the door is drawn, two spaces open it.** That includes a box holding only blank +lines, from a `ctrl+j` or an `alt+enter` you did not mean. It also includes the other +places: the same two spaces, typed into a place's own empty box — the tasks roster's filter, +the memory filter and the search query — open home from there. Places without a box, +such as spend and standing, count the two spaces directly. The door still +loses to a space that already means something where you are standing: on the settings panel +space is the row's `activate` verb, inside a task's record `space` pages the card, and on +home itself two spaces type into home's own box. -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +It works with a turn running — `esc` puts you back in it, still running. It does nothing +when the box already has words in it. It works on a machine with one conversation, on one +with none, and over `--host` — where what opens is the **far machine's** home. + +## What does pressing space twice do — the home door at the foot of a conversation + +When the box is empty, the keys row under the box ends with: + +``` +/ commands · space space home +``` -## What does pressing space twice do — space space does nothing now +The model controls and chats shortcut can precede it. It costs no extra row — it is the keys row the frame +already has — and it **vanishes the moment you type anything**, because it is a door +and not decoration. It also goes while a turn is running, where the same row has something +more urgent to say (`esc interrupt`); the gesture still works then, it is just not being +advertised. (Until 2026-09-17 these words were the right end of the rule above the box.) -Two spaces are ordinary text. The old Home shortcut is removed. Use `esc` to back out -to Home, even when a draft is nonempty or work is running. +**You can click it.** A press on the words `space space home` opens home; a press on the +rule beside them is a press on a rule. + +It appears on a fresh machine too, from the first minute, and over `--host` as well: a +machine with one conversation or with none still has a home to go to. The rule that keeps +home from *greeting* a first run is a different rule — not being greeted by home and not +being able to reach it are two different things. + +## space space does nothing — why the gesture did not open home + +Three reasons, and neither the machine holding nothing nor `--host` is one of them any +more: + +- **The box had words in it.** The gesture fires only when the second space arrives to find + a box with nothing in it a person would call text. ` x` and then two spaces is a draft. + The dim line at the foot is the honest test: if it reads `space space home`, two spaces + open home. +- **It was a paste.** Pasted text arrives whole and never reaches the key router, so two + leading spaces in a paste are two spaces (*Is there a key for home?*). +- **Home is already open.** On home, space is a character in the search box. + +A machine with one conversation, or with none, opens home all the same: its panels keep +their headings and the dim lines naming what arrives there (*Why is the home screen empty*), +not a refusal. So does a session over `--host`, which opens the **far machine's** home. ## how do I get back to home with one chat -Escape backs out to Home even on a machine with one conversation or none. Over `--host` -it opens the far machine's Home. `/home` and the clickable `esc back` hint work too. +Three ways, and they all work from the first minute on a fresh machine: + +- `space` twice on an empty message box +- `/home` +- a click on the words `space space home` in the dim line above the box + +The launch itself does not greet you with home while the only conversation on the machine +is the one it just opened — that is a rule about greeting, not about reach — so on a +machine with one chat, home is something you go to rather than something you land on. +What you find there is that chat as the first row of `threads`, saying `here`, and +this folder as the first row of `projects`. ## Is there a key for home? -Escape backs out one layer at a time until Home. `alt+1` (`opt+1` on a Mac) and `/home` -open Home directly where the current layer accepts those controls. +Three of them. **`alt+1`** goes straight there from anywhere — home is the first of the +four places on the tab bar, and each answers to its own position, `alt+1` through `alt+4` +(the three places off the bar answer `alt+5` through `alt+7`). +**Space twice on an empty box** goes there from inside a conversation, and **`tab`** walks to +it from any other place. `/home` opens it too. + +`alt+` arrives in every terminal codeaf runs in — it is sent as escape-then-digit and +has been for forty years — which is why the place keys are on `alt`. `ctrl+` has no +encoding a terminal can send at all. + +There is still no `ctrl+` chord for home: the plain ones are all taken (`ctrl+.` is the +tasks place, `/history`). ## What landed while I was away — since you left, and the look stamp @@ -1536,14 +1630,14 @@ three ways:** - **while it is asking you something**, it is a row of `needs you`, with the amber `?` and what it is asking under it; -- **while it is firing**, it is a row of `scheduled` like any other order — it is not a +- **while it is firing**, it is a row of `standing` like any other order — it is not a task and has no row on `sessions`. It is still the item — `ctrl+e` pauses it, `ctrl+x` stops it, and `alt+e` raises how hard it thinks; -- **while it is simply waiting for its time**, it is a row of `scheduled`, soonest first, with +- **while it is simply waiting for its time**, it is a row of `standing`, soonest first, with when it goes off at the right — `in 20h`, `mon 8:30`. `enter` on a question row opens the conversation that asked for it; on a `sessions` row it -opens the conversation; on a `scheduled` row it opens the standing place. +opens the conversation; on a `standing` row it opens the standing place. The `◦` mark itself belongs to the standing place and to a conversation's own lines — `◦ leave for the train · in 4m`. `∙` is a paused item there, and `◆` means the thing went off @@ -1589,7 +1683,7 @@ dialled to `max` still does not turn every check on the machine into a deep pass and an item nobody has dialled asks for nothing. **`alt+e` on an item's row is how you raise the one that deserves it.** Put the cursor on -the standing item — on home at rest it has a row under `scheduled`, or under `needs you` +the standing item — on home at rest it has a row under `standing`, or under `needs you` while it is asking you something — and press it: the rung climbs one step each press — `low`, `medium`, `high`, `xhigh`, `max`, then back to `low` — and home says `thinking high · ` at the foot. The item's sheet on a phone then carries a dim @@ -1718,18 +1812,11 @@ you go to confirm it really did look. ## Ask here — a reminder or a watch without opening a conversation -Type `/ask ` and press Enter to answer the sentence **in a pane of its own**. -The `/` menu offers `/ask `; choosing it writes `/ask ` and leaves the caret -ready for your question, like choosing `/task`. A bare `/ask` also waits for your words. -You can put `/ask` inside your sentence as an active command tag; it is removed before -the question is sent. More than one active submission tag keeps the draft and asks you -to choose one. Removing the tag restores ordinary submission. - -An ask is a real conversation with a transcript kept outside `~/.codeaf/v3/projects`, so -a one-off errand does not become an ordinary chat row. `/ask` from a conversation opens -Home and asks there too. If asking is unavailable, the draft remains editable. The -existing `ctrl+enter` shortcut still works on Home. No ask/new action rows or mode hints -appear above or below the message box. +While you are typing, the row directly above `start a new conversation` is +`ask here: "…"`. It answers the sentence **in a pane of its own** — a real conversation +with a real transcript, kept outside `~/.codeaf/v3/projects` so home never grows a session +row for a one-off errand. One `↑` reaches it, and `ctrl+enter` does it without leaving the +box. **Every exchange is a row below the conversation rows**, marked `?`, because it is the thing you asked for a minute ago — with what it is doing in the tail: @@ -1797,8 +1884,8 @@ drawn only when it has something to say: 9. one dim line naming the strip: `→ verbs: close, new in project, open folder, copy project`. -It never moves while the list lifts under your typing, and it stays empty until a -search result is selected. A frame too short for +It never moves while the list lifts under your typing, and it goes empty on the +`start a new conversation` row, because that chat does not exist yet. A frame too short for all of it drops bands from the bottom and never touches the name. Nothing that is zero is drawn. @@ -2008,7 +2095,7 @@ have. ## What is scheduled on home — reminders, routines, watches and rules, what is next up and when -**The `scheduled` panel, last of the seven**: every standing order this machine will act +**The `standing` panel, last of the seven**: every standing order this machine will act on, from every project, **soonest first**, with the rules that simply hold at the end. Four kinds of order stand on it — a **reminder** (`remind me at 6`), a **routine** (`every morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes`) and a @@ -2016,7 +2103,7 @@ morning at nine`), a **watch** (`tell me when CI goes red`, `when go.sum changes and **nothing at its right**: ``` - scheduled + standing the 6am repo watch top movers before the open tell me when CI goes red on master @@ -2039,7 +2126,7 @@ a date beyond it (`21 sep 9:00am`), and `now` once they have arrived. A routine never fired has no `last:`; a watch that has never looked has no `last looked`. An order in the middle of a pass says what the pass is doing instead of its clock. -**An order stopped on you is not on `scheduled`** — it is a row of `needs you`, with its +**An order stopped on you is not on `standing`** — it is a row of `needs you`, with its question, and comes back here the moment you answer. Paused, stopped and retired orders are not on it either. @@ -2048,7 +2135,7 @@ door into the standing place**, where the orders are kept and changed. With nothing standing it keeps its heading and `reminders, routines, watches and rules · "remind me at 6" or "every morning at 9"` — the -words that set one up. On a short terminal `scheduled` is the first panel to give way. +words that set one up. On a short terminal `standing` is the first panel to give way. ## How much did today cost — the spend panel on home @@ -2107,7 +2194,7 @@ Top to bottom: 1. `sessions`: the fifteen most recent conversations, combining open tabs and saved history without duplicates. 2. Additional question rows, without a heading — conversations not already listed above, - standing items that need a look, and `/ask` panes holding a card, from **any** project. + standing items that need a look, and ask-here panes holding a card, from **any** project. 3. Home ask exchanges retain their own answer rows. 4. `since you left` — what landed while you were not in the room. 5. **The projects.** This window's own project is drawn open with its remaining rows; every @@ -2124,8 +2211,8 @@ in the same chronological list. Other inbox rows are **two lines** — the label indented under it. A tab with a pending question carries an amber `?` on its existing row, including when the question belongs to a task inside that conversation. -**Typing still searches**, exactly as at every other width. Enter starts a conversation -by default; `/ask ` asks in a home pane. Only results appear above the seam. +**Typing still searches**, exactly as at every other width, with `? ask here` and +`+ start a new conversation` against the box at the foot. A tap on `since you left` opens memory. Other section headings fold their section away. Mouse motion does nothing at this @@ -2174,7 +2261,8 @@ session offered**, a window this one cannot reach is not answered, and once a ke the band reads `answered · waiting for it to pick that up` until the other session takes it. A window with no way to leave an answer draws no bands at all. -The bar under the box is the phone's legend: `open` on the inbox, `‹ back · open · more` on a sheet, `‹ back · send · more` on an errand. +The bar under the box is the phone's legend: at most three wide targets — `open · new · ask +here` on the inbox, `‹ back · open · more` on a sheet, `‹ back · send · more` on an errand. Tap one, or press the key it names. Below width **24** the plain hint line is drawn instead. ## Main chat versus subtasks — why is the work nested on Home? @@ -2221,7 +2309,7 @@ clears that unread state. Recently closed rows keep dim bullets. These indicator use this window’s live conversations; they do not infer unread history from other windows or persist read status across restarts. -In `/ask`, the thinking, writing and running indicator starts animating as soon as +In ask here, the thinking, writing and running indicator starts animating as soon as you submit, including follow-up messages. Linear mode keeps a still mark. ## How does Sessions behave on a short Home screen diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index ec6304379..321c14a96 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -730,7 +730,7 @@ everything you set up from it: **Home and the standing place both work over a connection**, and both are about the far machine: home's panels are that machine's — its questions, its running work, its -`scheduled` — and the +`standing` — and the standing place lists both what stands on this conversation and what stands anywhere else on that machine. `p` and `s` write to the far machine's store and the refusal, if the store refuses, is that store's own. The count at the foot of the task column — and the diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index ed9d6e11f..f81764857 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -84,7 +84,7 @@ one. |---|---| | `enter` | stops the current generation and sends the words into this turn | | `cmd+enter` | holds the message for an ordinary turn after this answer | -| `ctrl+c` | stops the answer and clears both waiting-message queues | +| `esc` | stops the answer and clears both waiting-message queues | | `→` over an empty box | steers the oldest waiting words into the running answer | | click `→ steers it in` | the same, with the pointer | | `ctrl+shift+enter` instead of `enter` | stops the answer and sends the sentence in one key — see below | @@ -96,7 +96,7 @@ Attachments in the tray go with the held message, and come back on the tray if y it back. `/`-commands are **not** held: a slash command is something you said to this surface rather than to the model, and it runs at once. -**Limits.** `ctrl+c` interrupts only while a turn is running; at rest it quits. +**Limits.** `esc` with nothing waiting is exactly the plain interrupt it always was. With messages waiting, it clears both the editable parked queue and the `ctrl+q` follow-up queue. Each nonempty queue says what was dropped — `1 waiting message dropped` or `N waiting messages dropped` for parked messages, and the corresponding `queued` @@ -166,11 +166,11 @@ line does not offer `→ steers it in`. right end of the row under the message box reads exactly: ``` -enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt +enter steers it in · ctrl+shift+enter stops and sends · esc interrupt ``` That is the terminal-capable form when no command can be kept. A running foreground -command adds `ctrl+g backgrounds` immediately before `ctrl+c interrupt`; a terminal that +command adds `ctrl+g backgrounds` immediately before `esc interrupt`; a terminal that cannot deliver `ctrl+shift+enter` leaves that clause out. `cmd+enter` still waits, but the one-line slot no longer advertises it. @@ -291,25 +291,25 @@ of ending work is `x` and a card that asks first. The chord is ignored there. can tell it apart from a plain `enter` — the kitty keyboard protocol, xterm's modifyOtherKeys, or win32-input. Where it cannot, the key arrives as an ordinary `enter` and your message **steers** instead. On those terminals codeaf never advertises the -chord. Use `ctrl+c` to stop the whole turn, then send the next message normally. +chord. Use `esc` to stop the whole turn, then send the next message normally. **The line that teaches it.** While a turn is running and you have typed something, the right end of the row under the message box reads exactly: ``` -enter steers it in · ctrl+c interrupt +enter steers it in · esc interrupt ``` On a terminal that can spell the secondary chords, the line reads -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt`. A foreground +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt`. A foreground command that can be kept inserts `ctrl+g backgrounds` before the final stop clause. **A picture on the tray is a message even when the box has no words.** It cannot steer, so -that form reads `enter waits · ctrl+c interrupt`, or -`enter waits · ctrl+shift+enter stops and sends · ctrl+c interrupt` on a terminal that can +that form reads `enter waits · esc interrupt`, or +`enter waits · ctrl+shift+enter stops and sends · esc interrupt` on a terminal that can spell the secondary chord. With neither words nor a picture, the line is simply -`ctrl+c interrupt`, unless a command can be kept, when it is -`ctrl+g backgrounds · ctrl+c interrupt`. +`esc interrupt`, unless a command can be kept, when it is +`ctrl+g backgrounds · esc interrupt`. ## I typed while it was working — did my message get lost? @@ -335,25 +335,17 @@ The one thing that is not answered is a message you queued with `ctrl+q` for a turn you then **interrupted**. A drain never restarts a turn you stopped, so those are dropped — press `enter` again to send it. -## Escape, esc, back, and getting home without stopping work +## Leaving for Home with a waiting message -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -**A message waiting above the box is preserved too, with its pictures, and still goes when -that answer ends even while Home or another chat is in front.** The exception is a -connection that holds one conversation at a time: its waiting words and pictures return -to the box and tray because the old conversation has ended. Escape never starts rewind or -stops a turn. `ctrl+c` interrupts a running turn and quits when idle; `/rewind` opens the -rewind timeline. - -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +A message waiting above the box stays with its conversation when you open Home with +`space` `space`, `/home`, or `alt+1`, and still sends when that answer ends. Its pictures, +pasted documents and standing mark stay with it. A connection that holds one conversation +at a time returns the waiting words and pictures to the box and tray when switching ends +the old conversation. `esc` stops the answer and drops waiting messages. ## Interrupting a running turn — how do I stop it mid answer -Press `ctrl+c` while a turn is running. The turn +Press `esc` or `ctrl+c`. While a turn is running, both do the same thing: the turn is stopped and everything it already said is kept. What happens: @@ -365,7 +357,9 @@ What happens: dropped`, or `N queued messages dropped`. 4. The status word becomes `stopping`, then `interrupted`, and `interrupted` stays as the status word until the next turn starts. -5. Parked messages are dropped too. Escape preserves both queues and goes back instead. +5. If a message of yours was **waiting** for that answer, it is *not* dropped: it sends + immediately as the next turn. That is the whole difference `esc` makes while + something is waiting. **The words codeaf uses for one stop.** They are five slots and one key press, so they are worth reading together: `stopping` is the status word while the turn is being let go, @@ -378,20 +372,23 @@ you are looking for the word *interrupted* anywhere else on the screen, that is is — the status line, and only after the turn has truly ended. **What the screen says.** While a turn runs, the right end of the row under the -message box ends with `ctrl+c interrupt` — for example -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while you have +message box ends with `esc interrupt` — for example +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt` while you have typed something and this terminal can deliver `ctrl+shift+enter`. A foreground command that can be kept inserts `ctrl+g backgrounds` immediately before the stop clause. When a message of yours is already waiting for the answer to finish, the last clause becomes -`ctrl+c stops and drops`. On the very first frame of a session the conversation carries the note -`esc back · ctrl+c interrupts or quits · ? for help`. +`esc stops and drops`. On the very first frame of a session the conversation carries the note +`esc interrupts · ctrl+c quits · ? for help`. **Stopping it and saying something new at once.** `ctrl+shift+enter` does both in one key — -see "Interrupt and say something new in one key" above. `ctrl+c` on its own stops without +see "Interrupt and say something new in one key" above. `esc` on its own stops without sending anything you have not already committed with `enter`. -**Escape is back, not stop.** It dismisses the nearest layer and eventually reaches -Home, preserving drafts and running work. Ctrl+C at rest quits the application. +**Limits.** Interrupting does nothing at all when no turn is running. `esc` reaches +the interrupt last: a history recall is cancelled first, rewind is armed on the way +past, and any open list or overlay takes the key before the message box sees it. So +`esc` while the command list or the `@` list is open closes that list and does +**not** interrupt. **Mid-turn `ctrl+c` only ever interrupts — that press never leaves.** It is spent on the model. The NEXT press is read at rest, and at rest `ctrl+c` is the way out — so the @@ -402,10 +399,11 @@ codeaf — how do I exit, close it, or why did ctrl+c not quit" below. ## Esc is not stopping it — how long does a stop take, why the turn is still finishing, how long stopping takes, and what happens if it will not let go -**Escape no longer stops work.** Use `ctrl+c` to interrupt. After Ctrl+C, the turn -may need a few seconds to let go; codeaf stops waiting after ten seconds. +**I pressed escape and it is still running.** That is this section: escape is not being +ignored, the turn is being let go of, and if it will not let go codeaf ends it for you +after ten seconds. -`ctrl+c` cancels the turn on the keystroke, but the turn does not close on the keystroke. A +`esc` cancels the turn on the keystroke, but the turn does not close on the keystroke. A `bash` call whose command left something holding its output waits up to three seconds before the pipes are forced shut, and a `jobs` kill spends two seconds on a polite signal and two more on the one that is not polite. For those seconds the status line reads @@ -427,8 +425,10 @@ becomes a row. Two things do still land, because neither can draw anything new: that was **already** on screen reports its own result if it returns in that moment, and what the turn spent is still counted. -**No key makes it stop harder.** The first Ctrl+C starts the ten-second window; -codeaf stops waiting when it expires. Another Ctrl+C at rest quits. Escape goes back. +**No key makes it stop harder, because the second stage is a clock and not a key.** A +second `esc` inside half a second is the rewind's door and `ctrl+c` at rest is the way +out, so neither is free — and you do not need one. The `esc` you already pressed started the +10-second window, and when it runs out codeaf stops waiting on its own. **What happens at 10 seconds.** codeaf detaches from the turn: the waits codeaf holds are ended and whatever request was still open to the model is aborted. A wait that ignores @@ -460,8 +460,8 @@ press to make, no window to beat, and nothing asking you to confirm it. writes your draft to disk and exits. **If `ctrl+c` did not quit, a turn was running.** Mid-turn that key is the interrupt — -the press is spent on the model. Escape only navigates back. Press it again once the -answer has stopped and codeaf exits. +the same thing `esc` does — and the press is spent on the model. Press it again once the +answer has stopped and codeaf leaves. **Nothing you typed is lost when you exit.** The unsent sentence in the box goes to disk, with any message that was still waiting for an answer folded in underneath it, and the @@ -485,8 +485,8 @@ one that does. The quit gesture is one `ctrl+c`, but where the press lands changes what it does. -**Mid-turn it is only the interrupt.** While an answer is streaming, `ctrl+c` -stops the turn, and that press does not leave. The next one, at rest, +**Mid-turn it is only the interrupt.** While an answer is streaming, `ctrl+c` is the same +key `esc` is: it stops the turn, and that press does not leave. The next one, at rest, does — so the two-tap people make mid-turn stops the model once and then quits. **It works over everything.** `ctrl+c` is read above every picker, panel, room, mode @@ -565,8 +565,8 @@ These apply with no overlay up, no room open, and no mode on. | `shift+enter` | Open a new line without sending, on home and in conversations | | `alt+enter` | Open a new line in a conversation | | `ctrl+j` | Same as `alt+enter` | -| `esc` | Back one layer, then Home; preserves message drafts and running work | -| `/rewind` | Opens the rewind timeline; repeated Escape never rewinds | +| `esc` | In order: cancel a history recall, then arm rewind, then interrupt the running turn — and send any message that was waiting for it | +| `esc` `esc` | Two presses inside a short window open the quick inline rewind mode. `/rewind` opens the full timeline instead | | `ctrl+c` | Turn running: interrupt, and nothing else. Nothing running: quit codeaf, on that press | | `ctrl+q` | Queue this message to run after the current turn. Empty box does nothing | | `ctrl+g` | A foreground command that can be kept: send that command to the background. Otherwise: close the task column, or bring it back. On a frame under 100 columns with no roster raised and no command to keep, it does nothing | @@ -918,7 +918,7 @@ your sent message too. It adds no characters and no cells; see "Slash commands a as chips" in the commands page for the whole of it. **A key chord is never given that background.** Where codeaf names a key — the hint slot -on the legend, the `/help` sheet, the opening `esc back · ctrl+c interrupts or quits · ? for +on the legend, the `/help` sheet, the opening `esc interrupts · ctrl+c quits · ? for help` — the chord is drawn one tier brighter than the words around it and nothing else changes. A tinted background always means a slash command and only ever that, so the two marks @@ -1066,7 +1066,7 @@ that has moved on. There is nothing to press; it is automatic. leave behind, and what `ctrl+enter` and `shift+enter` leave behind on a terminal that cannot send those chords, and nothing on the frame draws them. One kept on disk used to be adopted by the next window in the directory, which then opened with a box that - looked empty but still held invisible whitespace. + looked empty, was not, and refused `space space` for home. - The file is keyed by the directory plus this process's id, and is written with mode 0600. - At startup, if this window's own draft file is missing, codeaf takes the newest @@ -1900,9 +1900,9 @@ own line, so you can read it. **It does nothing on a machine with one conversation on it** — a first run, and nothing else — and says so by not being there: no card, and the keys row under the box does not -name it. Everywhere else that row reads `alt+e effort · alt+a approvals · alt+k chats · / commands · esc back` — `opt` in place of `alt` on a Mac. Effort and approvals appear only when the session +name it. Everywhere else that row reads `alt+e effort · alt+a approvals · alt+k chats · / commands · space space home` — `opt` in place of `alt` on a Mac. Effort and approvals appear only when the session has those controls. As the frame narrows, controls give way from the left, keeping -`/ commands · esc back`, then `/ commands` on its own. +`/ commands · space space home`, then `/ commands` on its own. **Taking a row is never refused for having too many open.** The card draws the first twelve rows and hands a digit to the first nine; past that the cursor is the way, and home is the @@ -2125,7 +2125,8 @@ to filter, `↑↓` to walk, `enter` to use it, `esc` to go back to the layer. ## Keys on home, and is there a shortcut for it -**Press `esc` to back out one layer at a time until Home.** `/home` opens it too. +**Press the space bar twice with an empty message box.** That is the way back to home from +inside a conversation, and `/home` opens it too. **There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the four places on the tab bar — `home tasks spend settings` — and each answers to its position there, @@ -2158,15 +2159,51 @@ such a page the line under the box names only the way out** — `tab next place tasks, on standing orders and on memory alike: a foot that offered `enter` or `type to filter` over a body with no rows would be naming a key with nothing to act on. -Press `esc` to go back one layer: close a picker, leave an editor or room, or put a -question aside. With no layer left, Escape opens Home. Further presses stay on Home. -Message drafts, running turns and queued messages are preserved. Filters may clear first. -Escape never starts rewind or stops a turn. `ctrl+c` interrupts a running turn and quits -when idle; `/rewind` opens the rewind timeline. - -The double-space binding has been removed. Spaces type normally in message boxes. -`/home` and `alt+1` (`opt+1` on a Mac) also open Home. Open a conversation row or use -`alt+k` to return to a conversation; Escape does not leave Home. +There is no `ctrl+` chord for home: every one this surface could use is already +taken, and `ctrl+.` is the tasks place (`/history`) from a conversation — while a place is +standing that same `ctrl+.` draws the map, on the terminals that can send it, because a place +takes the whole frame and never reaches the conversation's keys. `esc` was not available either: on an idle conversation it +already arms rewind and already clears messages waiting from the turn, and a third +meaning on one key in that state is how a surface stops being predictable. + +**The first space types itself.** The second one, finding a box that still shows nothing +with that space behind the cursor, takes the whole draft away and opens home — so a leading +space you actually wanted is never eaten (space then `x` leaves ` x`). It does nothing when +the box has words in it, and it is not a paste: text pasted with two leading spaces is two +spaces. A machine with one conversation, or none, opens an empty home; so does a session +over `--host`, where what opens is the **far machine's** home. + +**It answers from every place as well as from a conversation.** Wherever a place is +standing, the two spaces are read against that place's own filter — tasks, memory, search — +and open home just as they do from a draft; on spend and standing, which have nothing to +type into, two bare spaces open it and any key between them disarms it. On home itself the +door is a no-op: the page is already open, and two spaces type into home's own filter. It also does not answer from under a layer that owns the +keyboard: on the settings panel space is the drawn verb on a row (`activate`), +memory's card editor keeps every key while it is open, and inside a task's +record — the room the roster opens on `enter` — `space` pages the card the way +`pgdown` and `ctrl+f` do, so the door yields there and the key scrolls. Standing +cannot arm the door — its own keys never type into its box — but the box is the +shared composer, so a space left in it on another place still opens home from +standing. + +**A box that looks empty and is not still answers it.** Blank lines left by `ctrl+j`, +`alt+enter`, or by `ctrl+enter`/`shift+enter` on a terminal that cannot send those chords, +draw nothing on the frame — and the gesture reads the box the same way the frame does, so +two spaces open home and the blank lines go with the draft. The rule in one sentence: +wherever the foot advertises `space space home`, two spaces open it. + +It works while a turn is running; the answer keeps streaming underneath and `esc` puts you +back in it. + +When the box is empty, the keys row under the box says so: +`/ commands · space space home`, after any effort, approvals and chats hints. Clicking +`space space home` opens home; that clause vanishes as soon as you type. + +**The door does not ask what the machine holds.** It is open on a machine with only this +conversation and on one with none, from the first minute, and starting a second +conversation with `/new` changes nothing about it. It used to be shut until the launch +found somewhere else to go, and that rule is gone (the home page, *space space does +nothing*). Once it is open, **home is seven panels in one, two or three columns** (the home page has what each holds), and its keys are a small grammar: @@ -2176,15 +2213,15 @@ what each holds), and its keys are a small grammar: | `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the field, from one panel into the next, and stop at both ends: `↑` off the top row stays there and does not climb onto the tab bar (reach the bar with a click, `tab`, or a place's chord) | | `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on | | a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `unread` offers `1 accept 2 not right`, its own `[a]`/`[n]` being letters and letters always type on home | -| `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a `scheduled` row opens standing, a fold line opens or shuts its panel. `spend`'s lines are not stops, so the cursor never reaches them | +| `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a `standing` row opens standing, a fold line opens or shuts its panel. `spend`'s lines are not stops, so the cursor never reaches them | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | -| `esc` | dismisses a local layer; otherwise stays on Home and preserves the draft | +| `esc` | clears the box if anything is in it, and closes home otherwise | | `alt+.` | the map | | `backspace`, `ctrl+u`, `ctrl+w`, `ctrl+b`, `ctrl+f` | edit the box | | anything else | goes into the box, which searches the whole machine and offers to start a new conversation at the same time | -**Opening home puts the cursor on the chat you were in before this one**, so `esc` +**Opening home puts the cursor on the chat you were in before this one**, so `space` `space` then `enter` is a switch back; a window with only one conversation opens on its own row, which says `here`. The panel holding the cursor marks its heading with the cursor's ground, which is how you tell which column your arrows are in. **`alt+g` and `alt+q` are unbound on @@ -2211,7 +2248,7 @@ ones. Enter or `ctrl+e` on a closed row reopens it. **`ctrl+o`** opens its folder and **`ctrl+y`** copies its path. **`ctrl+t`** on a conversation's row still starts a new one in that row's folder, though `enter` on a row of the `projects` panel is the way home offers now. On a standing item's row — in `needs you` while it asks, in -`scheduled` otherwise, firing or not — **`ctrl+e` pauses** it, **`ctrl+x` stops it for good**, and +`standing` otherwise, firing or not — **`ctrl+e` pauses** it, **`ctrl+x` stops it for good**, and **`alt+e` raises how hard that item thinks** one rung. Each chord acts on the row under your pointer when there is one, the cursor's row otherwise. The machine's own default is not on this chord — it is the `thinking` row of `/settings`, and *alt+e — how hard the @@ -2256,21 +2293,24 @@ With all controls available the resting foot is `alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands`. The project and approvals hints are absent where those controls cannot act. `ctrl+o` still opens the selected row's folder and `tab` still moves to the next place, but neither -has a hint in home's bottom row. `esc` stays on Home; `alt+.` draws the whole map. +has a hint in home's bottom row. `esc` still closes home; `alt+.` draws the whole map. -Every ordinary grid row keeps the same list keys as the cursor walks. A fold names -its own keys, with the available draft controls before `esc`. Submission modes add no -footer hints. Plain text followed by Enter starts a new conversation; `/ask ` -asks in a home pane. +Every ordinary grid row keeps the same list keys as the cursor walks. A fold or action +row names its own keys, with the available draft controls before `esc`. For example: +`enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · alt+p project · alt+e effort · alt+a approvals · esc clear`. +Typing a slash command changes the first clause to `enter runs this command`. -**With nothing typed home is the panels. While typing, only search results appear above -the seam.** The best match is nearest the box. One `↑` selects it; `↓` past the last -result returns to composing. Clearing the box restores the panels. On wider frames, -the card beside the results follows the selected match. +**With nothing typed home is the panels**, hanging from the top. **While anything is typed +it is one list, a drop-up**: the action row — `start a new conversation: "…"` — is the LAST +row of the list, with `ask here: "…"` directly above it, both directly above the box, and +the matches rise above the pair **best one first**; the cursor starts on the action row, so +one `↑` reaches `ask here` and a second lands on the strongest match. Clearing the box puts +the panels back. On a frame 136 columns or wider a card about the match under the cursor +stands to the right of the list while you type; at rest there is no card. **On an `ask here` row** — the `?` rows an errand leaves at the end of the conversation list — the line under the box reads -`↑↓ move · enter or tab answer this ask here`. `enter` or `tab` hands the +`↑↓ move · enter or tab answer this ask here · esc close`. `enter` or `tab` hands the keyboard to the exchange's pane, where it reads `enter sends a follow-up · tab or esc back to the list`; `esc` or `tab` hands it back (*Asking from home*). @@ -2449,10 +2489,12 @@ words is news and the elbow's position is the whole of the record. They used to as fresh questions with a `›`, which made yesterday's correction read as a second instruction and made the page count turns nobody opened. -**`esc` in a room never interrupts and never stops.** Inside a room the first `esc` leaves the room and the next opens Home. Ending the task itself is `x` and its card. The legend's left end always +**`esc` in a room never interrupts and never stops.** Out in the conversation `esc` +interrupts the running turn; inside a room the first `esc` leaves the room and the next +one interrupts. Ending the task itself is `x` and its card. The legend's left end always names what the next `esc` does: `room · esc/←← main`, and `room · esc your line back` while a history walk is on. The keys row under the box reads `x stop` while there -is work here to stop and `↑↓ history` during a walk — it never reads `ctrl+c interrupt` +is work here to stop and `↑↓ history` during a walk — it never reads `esc interrupt` inside a room, because in here that is not what the key does. **A click inside the room's page does not leave it.** A press that lands on nothing — @@ -2983,7 +3025,7 @@ and it moves the rung of **the thing you are standing on**. One chord, three sco | The message box, typing or empty | **This conversation's** rung — the one on the legend above the box, beside the model, see *The thinking chip above the message box* | | The task roster holds the keyboard (`alt+t`) and the cursor is on a task | That task's rung | | You are inside a task's page | That task's rung | -| Home, with the cursor on a standing item's row — in `needs you` or `scheduled` | That item's rung | +| Home, with the cursor on a standing item's row — in `needs you` or `standing` | That item's rung | Everywhere else it does nothing at all. A conversation row on home is deliberately not on the list: a conversation's rung belongs to the window that conversation is open in, where @@ -3058,7 +3100,7 @@ trailing marks, and the turn carries straight on. Read it as "go on" — let the command run and get on with the work. This is the key for the moment you realise `go test ./...` is going to take nine -minutes. The alternatives are `ctrl+c`, which stops the turn and throws the run +minutes. The alternatives are `esc`, which stops the turn and throws the run away, and waiting. Afterwards it is an ordinary job: ask codeaf to list them, tail one, or kill one, @@ -3082,7 +3124,7 @@ been running longest**, which is the one you are waiting on. While a command can be kept, this meaning takes precedence over hiding or restoring the task column, so the column stays where it was. With no such command the key belongs to -the column as described above. `ctrl+b` is copy mode and `ctrl+c` interrupts; neither changes. +the column as described above. `ctrl+b` is copy mode and `esc` interrupts; neither changes. ## When a settings change lands @@ -3280,7 +3322,7 @@ live-applies on the next render; work stays indented in either mode. `/resume`, `/permissions` and the rest do: the commands page. - **The status line, the legend under the box, and the layout**: the screen page. - **Tasks, rooms, proposals and the roster**: the tasks pages. -- **Rewind**, which `/rewind` opens: the sessions and +- **Rewind**, which `esc` `esc` opens quick and `/rewind` opens whole: the sessions and rewind page. ## Starting a new chat with plus diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bc48c916f..ad3f82680 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -15,7 +15,7 @@ before each further try. A response proves endpoint reachability, not that every internet service is healthy. No separate public ping service is involved. Connection recovery waits up to two minutes, or less if that call already had -a shorter deadline. Ctrl+C or Stop work cancels your call immediately; other calls +a shorter deadline. Esc or Stop work cancels your call immediately; other calls still waiting keep their shared check. If the connection does not return, codeaf says `connection is still unavailable; try again when connected`. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bc61feaaf..1ec78e5ad 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -27,9 +27,8 @@ Every place is drawn in the same frame: 5. a rule, then the **composer** — one line you can type into, wherever you are 6. the hint line — what the keys do here -`esc` dismisses an editor or filter first, then returns to Home. The resting tasks, -spend and settings footers say `esc home`, including compact task screens. Places are not -stacked: opening one closes whichever was up. Further Escape presses stay on Home. +`esc` leaves a place and puts you back in the conversation you were in. Places are not +stacked: opening one closes whichever was up, so `esc` is always one press from the chat. ## How to get to a place — the keyboard shortcut to jump between pages @@ -55,7 +54,8 @@ Four ways, and they all reach the same seven rooms: conversations that match. A place ranks first, wears `▸`, and says `a place` out at the right margin. Home's list is a **drop-up** — it is read upward, out of the box you typed into — so ranking first means the offered place sits **below every conversation the same - words matched**, nearest the message box. One `↑` selects it. + words matched**, one row above `ask here` and `start a new conversation`, which is the + nearest row to your hand. Where the place can say what is behind it without going to the disk for it, the margin says that too: `a place · 6 orders, 1 fired today` on standing. A place that has nothing to count, or nothing in it, says `a place` alone. @@ -188,8 +188,9 @@ mark. On **search** the words you type are the query, drawn on the first row of same way, and `esc` clears them. On **memory** the head row echoes the filter in place of `type to filter`. Spend and standing take no text. -**Escape backs out to Home from every place.** Filters clear first where present; -editors and nested views close before their parent page. Two spaces no longer navigate. +**Two spaces still open home from every place.** On a place with a filter they are typed +into the empty filter and taken back out; on spend and standing, which have nothing to type +into, the two bare spaces are counted, and any other key between them disarms the door. ## The rule above home's box — where it lands, the model, thinking, approvals, what happened to the here ~/codeaf chip @@ -448,7 +449,7 @@ being held down — it only reports what arrived. The first place, and the one codeaf opens on. Everything on this machine, from every project, in one, two or three columns — one `sessions` list of the fifteen most recent -conversations, then question rows, `projects`, `since you left`, `spend`, and `scheduled`. +conversations, then question rows, `projects`, `since you left`, `spend`, and `standing`. Open tabs and saved history share that list, with closed conversations dimmed. Which column a panel stands in follows what it holds: every panel with rows is in the **field** at the left, and the **rail** at the right holds `projects` and `spend` at its top and, under @@ -594,7 +595,7 @@ one word most people guess for "what has this cost" printed one conversation's b never mentioned the machine-wide ledger. It opens the place now. **The foot names the keys this place has**, and it is built from the row under the cursor: -`enter opens what spent it · → the limits · shift+←→ move the days · tab next place · esc home`. +`enter opens what spent it · → the limits · shift+←→ move the days · tab next place · esc close`. Where the head row is too narrow to draw its own arrows the window clause is dropped, and over an empty ledger only the way out is named. @@ -796,7 +797,7 @@ afternoon. place segment and the legend under the box already carry. On a local session it is not there at all: a machine name is worth a word only when there is more than one machine in play. -## Why is home empty over ssh when I connect to another machine — Escape over --host +## Why is home empty over ssh when I connect to another machine — space space over --host **It is not empty any more, and this is the answer if you have seen it be.** @@ -808,7 +809,7 @@ the chat you came from keeps running, the same door `codeaf resume` uses locally It used to draw **one dim line** where the rows would be — `home shows this machine's projects, and this session is on another` — because the projects it could reach were the laptop's while the work was on the server. Before that it refused to -open at all. If you press Escape over a connection and get one line, the machine you are +open at all. If you press space space over a connection and get one line, the machine you are attached to is running an older codeaf than the one you are sitting at, and the fix is the same as for any version mismatch: update the older one. diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index e161e1cac..56c12d845 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -182,7 +182,8 @@ rule above the message box remain. Below 6 rows those give way too, leaving the the box, and the status line. Blank rows cannot activate the content beneath them. **Home at the left opens the home page**, keeping your conversation and unsent words. -It is separate from the tabs and breadcrumbs. Escape backs out to Home. Home disappears when the connection cannot open conversations, and on +It is separate from the tabs and breadcrumbs. Space twice on an empty composer still +opens Home. Home disappears when the connection cannot open conversations, and on very narrow frames the current tab takes priority. The switcher floats on a separate background inside a rounded outline, with space @@ -689,8 +690,8 @@ and it came off because a title takes the room the numbers need: the name is on strip at the top of the frame and on the breadcrumb bar, and nowhere else. Nothing stands in for it — an unnamed conversation draws the same line. -Until 2026-09-17 the right end carried the keys that work now (`esc back · / -commands`, `ctrl+c interrupt`); those are on the row under the box now — see *The keys row +Until 2026-09-17 the right end carried the keys that work now (`space space home · / +commands`, `esc interrupt`); those are on the row under the box now — see *The keys row under the box* below — and the numbers came up here from the last row of the frame, so that home and a conversation end in the same shape: a rule of facts, the box, a line of keys. @@ -761,15 +762,15 @@ long title could never push the numbers off the frame, and the name moved off ag 2026-09-17 it was the right end of the rule above the box; the numbers took that end and the keys got a row of their own. It names the keys that work right now when a state has keys of its own — for example `y allow · n deny · a always` while a question is up, -`ctrl+c interrupt` while a turn is running, -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+c interrupt` while a turn is +`esc interrupt` while a turn is running, +`enter steers it in · ctrl+shift+enter stops and sends · esc interrupt` while a turn is running and you have typed words on a terminal that can deliver the secondary key, -`enter waits · ctrl+shift+enter stops and sends · ctrl+c interrupt` while an otherwise empty +`enter waits · ctrl+shift+enter stops and sends · esc interrupt` while an otherwise empty box has a picture on its tray on that terminal, -`enter steers it in · ctrl+shift+enter stops and sends · ctrl+g backgrounds · ctrl+c interrupt` +`enter steers it in · ctrl+shift+enter stops and sends · ctrl+g backgrounds · esc interrupt` when that turn also has a foreground command that can be kept, or `↑↓ · enter · esc` while a list is open. A waiting message changes the final clause to -`ctrl+c stops and drops`; with neither words nor a picture the send clauses are absent. +`esc stops and drops`; with neither words nor a picture the send clauses are absent. **A question that cannot remember its answer loses the `a always` clause**, on this line and on the offer above it: a stuck turn is asked about with a scope codeaf cannot save, so the @@ -789,7 +790,7 @@ at least the first fitting clause remains, and a running turn never loses the ro because every clause would not fit. The row is the keys' own: nothing on the frame competes with them for it. -**The key itself is drawn apart from the word beside it.** In `ctrl+c interrupt`, `esc` +**The key itself is drawn apart from the word beside it.** In `esc interrupt`, `esc` wears the soft cyan every highlighted fact wears and `interrupt` stays at the border's own dim — the thing you press reads at a glance and the explanation of it does not compete. It is the same in every hint the slot carries, in home's foot hint, in the verbs @@ -799,21 +800,20 @@ below. **At rest it names the shared controls in home's order**, followed by the way home: ``` -alt+e effort · alt+a approvals · alt+k chats · / commands · esc home +alt+e effort · alt+a approvals · alt+k chats · / commands · space space home ``` On a Mac the modifier reads `opt`. Effort and approvals appear only when the session has those controls, and chats appears when there is another conversation to switch to. `tab` still returns to the last conversation but has no hint here. On narrow frames, -clauses give way from the left until `/ commands · esc home` remains, then +clauses give way from the left until `/ commands · space space home` remains, then `/ commands` alone if needed. -The conversation footer says `esc home`; a task conversation says `esc main`. Clicking -that hint takes the same route as Escape. Nested menus still close one layer first. -The hint remains available with a draft. `/` opens the command list. +Pressing the space bar twice on an empty box opens home; clicking `space space home` +does the same. That clause disappears when you type. `/` opens the command list. A state with its own keys, or an earned tip, takes over this row while it applies. -**Inside a task's room the slot is the room's**, and it never says `ctrl+c interrupt` there +**Inside a task's room the slot is the room's**, and it never says `esc interrupt` there — in a room `esc` leaves the page rather than interrupting anything. It reads `x stop` while there is work here to stop, `↑↓ history` while a history walk is on, and nothing otherwise. The left end of that legend is the room too: `room · esc/←← main`, or @@ -866,7 +866,7 @@ What steps up, in the lines you will see it in: | the legend's hint slot | the key, never the verb beside it | | `/help` | the key at the head of each row, never its explanation | | `/status` and `/cost` | the figure in the second column, never its label | -| the opening `esc back · ctrl+c interrupts or quits · ? for help` | the three keys | +| the opening `esc interrupts · ctrl+c quits · ? for help` | the three keys | Three rules hold it to one gesture, and they are worth knowing because they tell you what a mark means: @@ -1220,7 +1220,7 @@ words: | `working` | your own turn is over but work it handed out is still running — a task node in this conversation, or a background job; no spinner and no clock, which belong to a turn that is not running | accent | | `starting task` | a task proposal has a countdown and will start automatically | accent | | `waiting · your call` | an approval, standing or saved-program question requires an answer, or a task proposal has no countdown | the question hue, bold | -| `stopping · detaching in 7s` | you pressed `ctrl+c` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | +| `stopping · detaching in 7s` | you pressed `esc` and the turn has not finished letting go yet; the count is what is left of the 10-second bound before codeaf detaches | dim | | `interrupted` | the last turn was stopped by hand and is over | the bad hue | | `COPY` or `COPY · 12 lines` | copy mode | accent | @@ -1238,7 +1238,7 @@ against anything else. In the screen-reader tier the spinner is a still `*`. ## What the word stopping means in the status line, and why it is not interrupted yet -Because it has not finished stopping. `ctrl+c` cancels the turn instantly, but the turn does +Because it has not finished stopping. `esc` cancels the turn instantly, but the turn does not close instantly: a `bash` call whose command left something holding its output waits up to three seconds before the pipes are forced shut, and a `jobs` kill spends two seconds on a polite signal and two more on the one that is not polite. For those few seconds the @@ -1250,8 +1250,11 @@ nothing new is drawn — a reply the model was still speaking and a call it was through asking for both stop where they were rather than landing under the `interrupted` line. The word becomes `interrupted` the moment the turn is actually over. -**The second stop is a clock, not a key.** The window is bounded at 10 seconds from -the Ctrl+C you already pressed. Escape remains back navigation. The status line counts it down — `stopping · detaching in 7s` — +**The second stop is a clock, not a key.** There is no key to press, and there does not +need to be: `esc` again is the rewind's door (see the sessions and rewind page) and +`ctrl+c` at rest is the door, so neither is free, and a stop you have to ask for twice is a +stop that did not work the first time. So the window is bounded at 10 seconds from the +key you already pressed. The status line counts it down — `stopping · detaching in 7s` — and at the bound codeaf detaches: the waits inside the tool are ended, whatever request was still in flight is aborted, the conversation reads `detached — the turn was let go of and nothing is waiting for it`, and the turn is written to the journal as abandoned with @@ -1344,7 +1347,7 @@ Under 60 columns, eight things change shape: `enter`, or a **tap**, opens that row's card as a full-frame sheet whose top row reads `‹ back`; the card's answer chips become full-width answer bands, one per row, that a digit or a tap answers. The hint line under the box becomes one row of - at most three wide targets: `open` on the inbox, `‹ back · open · + at most three wide targets: `open · new · ask here` on the inbox, `‹ back · open · more` on a sheet. A tap **opens** — there is no second column to preview into, so there is no two-step — and mouse motion is ignored. Below width **24** the plain hint line is drawn instead of the bar. The rule over the box still says where the next @@ -1738,7 +1741,7 @@ glyph your messages wear in the conversation. Under it sits one dim line: ``` › do much more of a deep research please - waits for this answer · ctrl+c stops and drops · → steers it in · ↑ or click to edit + waits for this answer · esc stops and drops · → steers it in · ↑ or click to edit ``` The dim line trims from the right on a narrow terminal: the last piece goes first, then @@ -1749,7 +1752,8 @@ exactly one it is not counted at all. `→ steers it in` is there only while the message can go into the running answer: a turn still running, and a message of words alone. A waiting message that carries pictures, or one marked with `ctrl+enter`, cannot be sent in and the clause is absent for it. Pressing -`esc` opens Home and leaves the block with this conversation. `ctrl+c` stops the answer +`space` `space` opens Home and leaves the block with this conversation. `esc` or +`ctrl+c` stops the answer and removes the waiting block at once; `→ steers it in` is absent while a stopped turn is winding down because that turn has no boundary left to take the words. @@ -1769,7 +1773,7 @@ What happens to it: `a connection holds one conversation at a time`, switching ends the old conversation, so nothing can keep waiting on its answer. The waiting words return to the box and their pictures and pasted documents return to the tray after anything already there. -- **`ctrl+c`** stops the answer and drops every parked message and queued follow-up. None +- **`esc`** stops the answer and drops every parked message and queued follow-up. None starts a turn when the interrupted stream closes. - **`→` over an empty box**, or a **click on the words `→ steers it in`**, sends it **into** the running answer instead of leaving it to wait. A streaming generation @@ -1786,7 +1790,7 @@ What happens to it: dropped and codeaf says so: `1 waiting message dropped` or `N waiting messages dropped`. While something is waiting, the keys row under the box ends with -`ctrl+c stops and drops` instead of `ctrl+c interrupt`. +`esc stops and drops` instead of `esc interrupt`. ## Long lines inside a fence — code cut off at the edge, the tail of a line missing, `↳` @@ -3070,8 +3074,8 @@ What stands in their place is **one dim door** at the foot of the column: `ctrl+ **Everything earlier lives one press away.** `ctrl+. earlier` opens the full-screen task page (`ctrl+.`, `/history`), which holds every task the project has ever run, across every -session, with the filter, the cards and the mention. Home (`/home`, or Escape from the -conversation) is the other place old work is listed. Running work belonging to *other* +session, with the filter, the cards and the mention. Home (`/home`, or space twice on an +empty box) is the other place old work is listed. Running work belonging to *other* windows is not on the column at all, and never was; `/history` carries that too. The footer is up to three dim lines of counts — `3 running · 1 needs you`, `148 waiting · @@ -3526,7 +3530,7 @@ models produce a run of thought before the answer, on the same connection, bille way, and on a big conversation it can run for a minute before a word of answer appears. Nothing is wrong. The clock beside the word counts up, so a number that is moving is a program that is alive and painting; a clock that has **stopped** is the thing to worry -about. `ctrl+c` interrupts at any point. +about. `esc` interrupts at any point. `first word` is the other slow one, and it means something different: codeaf's request was accepted and the endpoint has written nothing at all — a queue, a cold model loading, or a @@ -3598,7 +3602,7 @@ thinking. A known phase is shown separately because it has better information. An advancing clock confirms the view is repainting. A still indicator alone does not prove a freeze: reduced-motion views use static marks, and narrow rows -can omit the clock. `ctrl+c` interrupts the turn. +can omit the clock. `esc` interrupts the turn. The waiting clock stops when the stream speaks or tools run. A tool uses its own activity and elapsed time. The request after a three-minute `go test` starts a diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 40777ee38..854f48b14 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -2,17 +2,38 @@ ## Taking a message back — rewind, how do I undo something I said -Use `/rewind` (aliases `/undo`, `/back`) to open a searchable timeline of the whole -conversation. Choose a point, press `enter` to place the pick, then `enter` again to -cut everything from that point onward. Escape clears the filter or closes the timeline. +Rewind cuts the conversation back to an earlier point and drops everything after it. Use it +when you phrased something badly and want to say it again better. -Repeated Escape is back navigation and never opens rewind. Rewind changes what the -model has been told; it does not undo files, commands or git changes. +**There are two tiers, and they answer two different questions.** + +| Way in | What you get | +| --- | --- | +| `esc`, then `esc` again within half a second | The **quick** inline mode: a cut line drawn through the transcript already on screen | +| `/rewind` (aliases `/undo`, `/back`) | The **rewind timeline**: the whole conversation as a fullscreen list, with a search and a preview | +| `tab`, from inside the inline mode | Lifts the inline mode into the timeline, carrying the cut you had already chosen | + +The first `esc` keeps its ordinary meaning — mid-turn it interrupts, at rest it does nothing +— and also arms rewind. The arming window is **500ms**. While it is warm, the hint slot says +exactly `esc again to rewind`. A stray `esc` after the window has lapsed changes nothing. +The command row for `/rewind` reads `go back to an earlier point · esc esc takes back the last`. + +Both tiers use the same `⟲` glyph, the same "drops N turns" arithmetic, the same cut, and +do the same things afterwards. Which one to reach for: `esc esc` for "not that, let me say +it again", `/rewind` for "take us back to before we started down this road". + +Rewind edits what the model has been told. It does not undo work that was done. Read the +section on what rewind does not undo before you rely on it. ## Seeing the whole conversation — the rewind timeline `/rewind` opens a fullscreen page listing the **whole** conversation, oldest first, with no -scrolling needed to get at the old end of it. The timeline reaches the whole conversation without loading older transcript blocks first. +scrolling needed to get at the old end of it. This is the one that can reach turns the +inline mode cannot: the inline mode picks out of the transcript **drawn on screen**, and a +conversation you came back to opens showing its last **40** blocks. Scrolling up pulls the +older ones in 40 at a time until you reach the first message (see the screen page), so the +inline mode reaches as far back as you have scrolled — and `/rewind` reaches the whole thing +without your having to. What is on it: @@ -64,9 +85,16 @@ not keep them, and codeaf will not invent them. ## Jumping to an old message from far back in the conversation -Open `/rewind`, type a word from the message, and press `enter` twice on the desired -point. The search reaches the entire conversation. If the cut is refused, the page stays -open with the reason in the foot. +Open `/rewind`, type a word you remember from the message, and press `enter` twice. That is +the whole route. The search reaches the entire conversation, not just the part drawn on +screen, which is why `esc esc` is the wrong tool for anything older than the last screenful. + +`tab` inside the inline mode does the same thing without retyping the command: it lifts you +onto the timeline with the cut you had already chosen, and your half-written draft comes +with you. The inline mode's own legend names the key. + +If the cut is refused, the page stays up with the engine's sentence in the foot, and the +same `enter` retries it a moment later. ## What rewind does NOT undo — your files stay changed @@ -132,9 +160,42 @@ the turn. The screen shows what was removed, so you can see this happen. ## Moving the cut line in the quick inline mode, and what the screen says -The double-Escape entry to inline rewind has been removed. Use `/rewind` to search the -whole conversation, preview a point and confirm the cut with two presses of `enter`. -Escape backs out without cutting anything. +This is the `esc` `esc` mode — the transcript on screen with a line drawn through it. The +keys are: + +| Key | What it does | +| --- | --- | +| `↑` / `↓` | Walk whole turns | +| `←` / `→` | Step through the points inside the current turn | +| `enter` | Commit the cut | +| `tab` | Lift into the rewind timeline, carrying this cut | +| `esc` | Leave with nothing changed | + +The mode bar prints exactly +`↑↓ turns · ←→ steps · enter rewind · tab the whole conversation · esc back`. + +The cut opens on the last thing you said — the newest turn point — or on the newest point of +any kind when there is no turn point at all. `←`/`→` are bounded by the current turn, so a +step walk cannot leave it. `↑`/`↓` never fall off either end. + +While the mode is up it takes **every** key. Nothing falls through to the draft box, because +the mode bar is standing where that box was. `ctrl+c` is read above it and stays the way +out — it does not leave rewind, it quits codeaf, with the mode still up. + +The mouse can do everything the keys can: click any transcript row to move the cut, click +the cut line itself to commit. A click chooses the nearest point at or above the row you +pointed at, so a click never drops less than the row you aimed at. A click above every point +takes the oldest one. Nothing here is pointer-only. + +**What you see:** the bar that replaced the draft box reads `⟲ drops 2 turns` — the glyph, +the word `drops`, and the count. A cut landing on a turn boundary is counted in turns; a cut +inside the newest turn takes no whole turn and is counted in steps instead. On a +screen-reader ("linear") palette the glyph is `<<`. On a frame too narrow for both, the key +legend goes and the count stays. + +The cut line itself is a horizontal rule drawn above the chosen block, labelled +`⟲ rewind here` (`<< rewind here` in linear). Everything from the line down is repainted +dim — accents, diff colours and all — because "all of this goes" is the true statement. ## What happens when you press enter on a rewind @@ -169,8 +230,12 @@ place to cut. `/rewind` will not raise an empty timeline. inline mode is already on, copy mode is on, a task room is open, the settings panel is open, or the task rail is full. The commands page lists them. -**Repeated Escape never enters rewind.** It dismisses the current layer and eventually -lands on Home, without stopping work or dropping drafts. Use `/rewind` explicitly. +**`esc` `esc` does nothing.** `esc` will not arm rewind when something else on the surface +holds the keyboard. That is: the settings sheet, the deck, the expand view, the model +picker, the resume roster, the connections panel, the command menu, the completion list, the +welcome box, copy mode, a recall walk, an open room, the rail hold, fullscreen rail, an +approval question, an awaited task proposal, an active guard, any pending connect ask, and a +pending harness offer. Close or answer that thing first. **The sentences a rewind can come back with:** @@ -392,7 +457,7 @@ lands you on the ordinary prompt. So the greeting is what a **first run** sees — the launch where home has nothing to say, because the only conversation on the machine is the one already on screen. That is the one case where the wordmark and the centred message box greet you; home itself is still a -`esc` away. On a profile with nothing configured yet, the once-only setup screen +`space space` away. On a profile with nothing configured yet, the once-only setup screen comes first (the getting-started page), and the greeting arrives the moment it closes. ## The "resumed" line at the top — what it says, and where the file path went diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 2b323a93b..6df039bd4 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -88,7 +88,7 @@ underneath it. `esc`, or `enter` on the row the cursor starts on, drops into tha conversation; everything after that is the chat exactly as it always was. Home stays out of the way when you name a conversation, on a `--once` or `--host` run, and on a machine whose only conversation is the one already open — though it is -still there to go to: `esc` from the conversation, or `/home`, opens it on that +still there to go to: `space` twice on an empty box, or `/home`, opens it on that machine too. The home page covers the whole of it. Run it in the directory you want it to work in. That directory is where it stands diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index f9bb2e38d..59e10ee3a 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -245,7 +245,7 @@ you are looking at is the line above. **Nothing is lost and nothing is closed.** The window is still attached: replies arrive live, the transcript is complete, you can scroll it, copy out of it, answer a permission -card, press `ctrl+c` to interrupt, and walk to any other place with the usual keys. What you +card, press `esc` to interrupt, and walk to any other place with the usual keys. What you cannot do is send a message — and typing characters does nothing at all, because there is no box on the screen to put them in. @@ -268,7 +268,7 @@ There is no lock and nothing to release. Whoever pressed `enter` most recently h the window that lost it keeps its own draft, its own scroll position and the whole conversation. -**Walking away instead:** Escape backs out to Home, exactly as they do +**Walking away instead:** two spaces in an empty box still open home, exactly as they do when you are typing, so you can leave the conversation running in front of the other window and get on with something else on this machine. diff --git a/internal/manual/chat/subharnesses.md b/internal/manual/chat/subharnesses.md index cac279dde..25ab12357 100644 --- a/internal/manual/chat/subharnesses.md +++ b/internal/manual/chat/subharnesses.md @@ -174,16 +174,15 @@ this machine, and `/home`, sees this conversation as `waiting on you`, with the | `←` / `→` | walk the answers | | `enter` | take the answer under the cursor | | `1` | run it, from anywhere on the card | -| `0` | no — nothing runs | -| `esc` | defer; `/subharness` returns to this card | +| `0` or `esc` | no — nothing runs | | `↑` / `↓` | move between the fields and the answers | | `enter` on a field | open the box and type its value | -The hint under the answers reads `←→ · enter takes it · 0 no · esc back`. +The hint under the answers reads `←→ · enter takes it · 0 or esc, no`. -**`esc` defers the offer without answering.** `/subharness` reopens the same pending card -with your field edits intact. The note reads `subharness waiting · /subharness to return`. -`0` explicitly declines. Another Escape goes back toward Home. +**`esc` here is a no and not a way out.** A turn is waiting on this question, so the key +that dismisses every other overlay answers this one instead — nothing runs, and the +conversation carries on immediately. **If you never answer it, nothing runs.** The offer holds the turn for at most **15 minutes**; when that runs out the card comes down by itself, the line under the diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bfbd8c8a7..db5ca0787 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2345,7 +2345,7 @@ another one's without the column ever saying it had. Everything they offered is other side of the door, whole: every row, the filter, the cards, and `m` for the mention. **Where old work is listed now:** the task page (`ctrl+.`, `/history`, or that line), and -home (`/home`, or Escape from the conversation). The chat can also read the whole project +home (`/home`, or space twice on an empty box). The chat can also read the whole project record for you with its `tasks` tool — just ask. **Running work in another codeaf window** is on no surface but the task page. An ordinary @@ -3319,13 +3319,13 @@ local conversation the same page tails that log live. | clicking empty space | nothing | nothing — leaving is `esc`, `←`, or the pinned header | | what `enter` does | sends to the model, or holds the message above the box while a turn is running | **steers the task** — never held | | what `↑`/`↓` do | walk your history, then select a tool row, then scroll | the same walk through **the same history** — steered lines are in it — then scroll the page | -| what `esc` does | backs out to Home, preserving work | leaves the room. It never interrupts and never stops work | +| what `esc` does | interrupts the running turn | leaves the room. It never interrupts and never stops work | | how you stop the work | `esc` | `x` over an empty box, which raises the confirmation card | | the box's own line | the bare `› ` | a tinted segment naming the task, in its state's hue, then `› ` | | box placeholder | the draft prompt | `Steer this task… (esc: main)`, or `Steer … (esc: main)` where the frame is too narrow for the segment | | pinned top rows | the pulse line, the tab strip under it, one thin rule and a blank — the same four rows every place draws; a dim `+N` at the strip's right end counts the tabs it could not spell, and `alt+k` opens the chats card | the same four rows — pulse, tab strip, rule, blank — so the rule does not move when you walk in; then a breadcrumb row (conversation → ancestor tasks → current task) and a quiet facts row under it | | legend word | the model, effort and approvals, with the remote machine when connected | `room · esc/←← main`, and `room · esc your line back` while a history walk is on | -| legend hint | `ctrl+c interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | +| legend hint | `esc interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise | | the model on the status row | the conversation's model | `task <the task's model>` | | clicking that model | opens the picker and switches the conversation | opens the picker and switches **that task**, from its next request — and does nothing at all once the task has landed | | `ctrl+b` | freezes the transcript | freezes the room's own rows | @@ -5320,7 +5320,7 @@ changes with the cursor: The final clauses describe the **page** rather than the row: -- `esc home` returns to Home when the filter is clear. +- `esc close` returns to the conversation when the filter is clear. - `type to filter`, because nothing else on the frame says that a letter goes into the box on the control row rather than to the page's own keys. While a filter **is** on, that slot says `esc clear the filter` instead — the one fact the box itself cannot show is that esc diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 50a80a8f9..ecbacea2f 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -250,7 +250,7 @@ not. Two things it does **not** do: -- **A command you interrupted is interrupted.** Pressing `ctrl+c` cancels the turn, +- **A command you interrupted is interrupted.** Pressing `esc` cancels the turn, and a cancelled command is never kept as a job: it dies, no job appears, and the answer is `Command aborted`. Stop means stop. - **It does not outlive the conversation.** A foreground command kept as a job is a job, so it is @@ -845,7 +845,7 @@ absolute path**, however you spelled it in the call: **One look gets ten minutes**, and then the tool answers without it. A model that takes the picture and goes quiet used to leave the row running for the rest of the conversation; now the window runs out and you get the line above instead. -Press ctrl+c and the look stops on the same beat everything else does. +Press esc and the look stops on the same beat everything else does. **When no looking model can be reached, the tool is not there at all** — it is left off the toolbelt rather than offered and made to refuse. Ask for a picture diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a935d2855..e718e3f69 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1868,6 +1868,10 @@ type app struct { // sends no status-line news (hostlink.go's [app.sayNewsSilence]). It is said // once per window, because it is a fact about a machine and not about a turn. newsSilenceSaid bool + // watchSpaces counts the run of spaces a WATCHER has typed, which is how the + // door home is reached from a register with no box on the frame + // (watching.go's [app.watchKey]). It is zero everywhere else. + watchSpaces int // linkLatency is the hosted connection's rolling round trip, and // linkPingAsking keeps its slow clock to one call at a time. Both are zero on // every local session and before the first hosted answer, which the @@ -1971,6 +1975,7 @@ type app struct { // so it is refreshed on the paint clock while something on screen is drawing // it and held between times. away elsewhereCache + // pilots are the watchers on the nodes that are running right now, keyed by // id, and pilotGen the counter each one takes its generation from (task.go). // Empty is the ordinary state: nothing is running, so nothing is watched. @@ -2190,6 +2195,10 @@ type app struct { // not now that a conversation draws it too (pulsebeat.go). Every figure in it // is read from the MACHINE, never from what a screen was holding (#525). machine machineFacts + // placeSpaceArmed is the first of the two spaces that open home from a + // place with no box — spend, standing — held until the second lands or any + // other key disarms it (placekeys.go's [app.placeHomeGesture]). + placeSpaceArmed bool // pageMsg is the one refusal a place that is not home has to say, drawn where // the hint would be. It is one field for [homeView.msg]'s reason: pressing a // door twice says the same thing once. @@ -2424,7 +2433,11 @@ type app struct { // can sit on, and the draft it is holding (rewind.go). Closed, it costs the // frame nothing. // - // rewSay and rewSayAt hold a temporary rewind refusal on the frame clock. + // escArm is when the first esc landed, or zero — the door's other half, which + // lives out here rather than inside the mode because it is a fact about the + // mode being DOWN. rewSay is one sentence the mode could not act on + // ("nothing to rewind") and rewSayAt when it was said; both run down on the + // frame clock ([app.rewindSweep]), because this surface has one clock. rew rewindMode // rewSheet is the DELIBERATE rewind: the whole conversation as a full-frame // timeline, with a search, a preview of the pick and a two-stage enter @@ -2432,6 +2445,7 @@ type app struct { // whole, and it is built from the session's own transcript rather than from // the drawn blocks — which is why it can reach turns the inline mode cannot. rewSheet rewindSheet + escArm time.Time rewSay string rewSayAt time.Time // tmux says this surface is inside a multiplexer, so a clipboard write has @@ -2684,7 +2698,7 @@ func (a *app) noteKilled() { // opens with, which is already about the keys nothing else names, is where it is // written down. It is the third and last clause because the two in front of it // are about the session a person is in and this one is about the program. -const landingKeysWord = "esc back · ctrl+c interrupts or quits · ? for help" +const landingKeysWord = "esc interrupts · ctrl+c quits · ? for help" func newApp(ctx context.Context, opts Options) *app { // THE ENVIRONMENT IS READ THROUGH THE SEAM AND NOWHERE ELSE, so the four @@ -2971,8 +2985,8 @@ func newApp(ctx context.Context, opts Options) *app { // IT HAS TO BE TRUE IN EVERY STATE, and the line it replaced was not: it // promised an interrupt on the first frame of a session where nothing was // running, and at that moment ctrl+c was the door rather than a stop. The - // clauses distinguish back navigation from the stop-or-quit key: Escape - // goes back, while Ctrl+C stops a running turn or leaves at rest (leaving.go). + // two clauses here are each true whatever is happening — esc stops the turn + // when there is one, and ctrl+c at rest always leaves (leaving.go). // // AND IT WAITS FOR THE GREETING TO GO. On an empty session the line lands // when the conversation begins rather than above a screen that is asking for @@ -4929,7 +4943,10 @@ func (a *app) paint() tea.Cmd { // update wakes this clock, and the clock keeps turning while the store // says the run is still out (homestanding.go, standing.go). a.standingAnimating() || - // Rewind refusals expire on the shared frame clock. + // AND THE REWIND ARM IS THE SEVENTH, and the only one of them that turns + // with nothing on screen moving at all: the hint slot says "esc again to + // rewind" for half a second, and something has to be drawing the frame + // that takes it away again (rewind.go). a.rewindTicking() || // AND A STOP BEING LET GO OF IS THE TENTH, and it is the third that turns // with nothing on screen moving at all — a stopped turn draws nothing new @@ -7177,9 +7194,6 @@ func (a *app) slash(line string) tea.Cmd { // the shape every choice row on this surface refuses in. return a.runEffort(rest) - case "ask": - return a.runAskCommand(rest) - case "task": return a.runTaskCommand(rest) @@ -7278,7 +7292,7 @@ func (a *app) slash(line string) tea.Cmd { case "rewind": // THE COMMAND IS THE DELIBERATE DOOR AND IT OPENS THE TIMELINE - // (rewindsheet.go). Escape remains back navigation + // (rewindsheet.go), while esc esc keeps the quick inline gesture // (rewind.go). Somebody who typed six letters to get here has already told // this surface that the answer is not the message they just sent — it is // somewhere back in the conversation, and finding it wants the whole of the @@ -7460,7 +7474,7 @@ func (a *app) freshAndEmpty() bool { return false } // A NOTE IS NOT A CONVERSATION. Every surface opens with the surface's own - // lines on it — `esc back · ctrl+c interrupts or quits`, a door's notice, a + // lines on it — `esc interrupts · ctrl+c quits`, a door's notice, a // refusal somebody read — and counting those would make "fresh and empty" // false on the very first frame of every session, which is the one state // this test exists to recognise. @@ -7747,7 +7761,7 @@ func (a *app) quit() tea.Cmd { return tea.Quit } -// interrupt is ctrl+c: stop the turn, keep what it said. +// interrupt is esc: stop the turn, keep what it said. // // EVERYTHING A STOP OWES THE SCREEN IS PAID AT THE KEY, and that is the whole of // what this function changed when the interruption wave went through it. The @@ -7760,7 +7774,7 @@ func (a *app) quit() tea.Cmd { // disagreeing with the one fact the person is certain of — they pressed the key. func (a *app) interrupt() { a.interruptTurn() - // CTRL+C STOPS EVERYTHING, including both ways a later turn can already be + // ESC STOPS EVERYTHING, including both ways a later turn can already be // waiting. The session drops its follow-up queue on interrupt; the surface // drops that mirror and its editable parked queue in the same keypress so the // stream close cannot orphan or unexpectedly send either one. @@ -7770,7 +7784,7 @@ func (a *app) interrupt() { // interruptForBarge stops the current turn but preserves the draft // [app.bargeIn] just parked. ctrl+shift+enter promises stop-and-send; it shares the -// stop machinery with ctrl+c without sharing ctrl+c's queue-clearing decision. +// stop machinery with esc without sharing esc's queue-clearing decision. func (a *app) interruptForBarge() { a.interruptTurn() a.dropFollows() @@ -7832,7 +7846,7 @@ func (a *app) interruptTurn() { a.note("stopped") // AND THIS TURN PROMOTES NOTHING (hierarchy.go's [app.cutTurn]). The mark goes // on the blocks at the keypress so the demotion is on screen the moment the - // person presses ctrl+c, and again when the stream finally closes ([app.settle]), + // person presses esc, and again when the stream finally closes ([app.settle]), // because events in flight land between the two. a.cutTurn(a.turn) } @@ -7844,9 +7858,25 @@ func (a *app) interruptTurn() { // it are still true and the second one is what dictated how this was built. What // changed is that the thing it said did not exist now does. // -// The stop bound is a clock, not a second key: Ctrl+C already asked the turn -// to stop, and Escape must remain navigation. The deadline starts on that -// first stop and releases a turn that will not finish letting go. +// WHAT THE ARGUMENT GOT RIGHT, FIRST HALF: THERE IS NO KEY LEFT. esc's grammar +// in the conversation is read in a fixed order (input.go, rewind.go): a recall +// walk takes it, then [app.escRewind] — where the first esc ARMS the rewind on +// its way past and a second one inside [rewindArmWindow] OPENS it — and only +// then [app.interrupt]. So every esc that lands within half a second of another +// esc already belongs to rewind, and THE INTERRUPT IS NOT FOR SALE cuts the +// other way just as hard. Putting a hard stop AFTER the window does not save it +// either, because an esc past the window is a FIRST esc again, so the key would +// mean "stop harder" or "open the rewind" depending on what the person did half +// a second later — one keypress with two readings, which is the one thing this +// keyboard cannot have. ctrl+c is spoken for on both sides of the same moment: +// mid-turn it is the interrupt, and at rest — which is what winding down IS — +// it is the door (leaving.go). +// +// THAT REMAINS TRUE, SO THE SECOND STAGE TAKES NO KEY AT ALL. It is a CLOCK, +// started by the esc the person already pressed, and it needs no grammar because +// it asks for no gesture. A person who wants a turn to stop has said so once; +// making them say it twice, harder, into a surface that already heard them is +// the exact experience issue #265 was filed about. // // WHAT THE ARGUMENT GOT RIGHT, SECOND HALF, AND WHY IT DICTATED THE ORDER OF // WORK: A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN. A deadline whose diff --git a/internal/tui3/back_test.go b/internal/tui3/back_test.go deleted file mode 100644 index 041edd1e5..000000000 --- a/internal/tui3/back_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package tui3 - -import ( - tea "charm.land/bubbletea/v2" - "github.com/Agent-Field/codeaf/internal/session" - "testing" - "time" -) - -func backApp(t *testing.T) *app { - t.Helper() - lab := newHomeLab(t) - mine := lab.session("-alpha", "aaaa000000000001", "here", "/tmp/alpha", time.Now()) - return lab.app(mine) -} - -func TestEscapeReachesHomeAndPreservesRunningWorkAndDrafts(t *testing.T) { - a := backApp(t) - agent := &rewindFake{fakeAgent: &fakeAgent{model: "m", past: rewindPast()}} - a.agent = agent - a.state = stateWorking - a.parks = []parked{{text: "parked words"}} - a.follows = []queued{{text: "queued words"}} - a.input.setText("half a thought\nand another line") - for range 8 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.rew.on || a.rewSheet.open || agent.stops != 0 || len(agent.cuts) != 0 || a.state != stateWorking { - t.Fatal("Escape did more than navigate home") - } - if len(a.parks) != 1 || len(a.follows) != 1 { - t.Fatal("Escape dropped waiting messages") - } - if a.input.String() != "half a thought\nand another line" { - t.Fatal("lost conversation draft") - } - a.home.box.setText("a home draft") - for range 8 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.home.box.String() != "a home draft" { - t.Fatal("Escape left Home or lost its draft") - } - a.closeHome() - if a.input.String() != "half a thought\nand another line" { - t.Fatal("returning lost the draft") - } - drive(t, a, key("ctrl+c")) - if agent.stops != 1 { - t.Fatal("Ctrl+C no longer interrupts") - } -} - -func TestEscapeDismissesCommandListsWithoutLosingEitherDraft(t *testing.T) { - for _, home := range []bool{false, true} { - a := backApp(t) - if home { - a.openHome() - typeHome(a, "/mo") - } else { - a.input.setText("/mo") - a.syncLists() - } - drive(t, a, key("esc")) - if home { - if a.home.cmd.open || a.home.box.String() != "/mo" { - t.Fatal("Home command dismiss lost draft or kept list") - } - } else { - if a.menu.open || a.input.String() != "/mo" || a.at(pageHome) { - t.Fatal("conversation command dismiss skipped a layer") - } - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) { - t.Fatal("Escape did not settle on Home") - } - } -} - -func TestEscapeFromEveryPlaceSettlesOnHome(t *testing.T) { - for _, where := range []page{pageHome, pageTasks, pageSettings, pageMemory, pageSearch, pageSpend, pageStanding} { - t.Run(string(rune('0'+where)), func(t *testing.T) { - a, _ := driveToPlace(t, newHomeLab(t), where) - for range 12 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) { - t.Fatalf("%v did not reach Home", where) - } - }) - } -} - -func TestDoubleSpaceNoLongerNavigates(t *testing.T) { - a := backApp(t) - for _, draft := range []string{"", "\n", "words"} { - a.input.setText(draft) - drive(t, a, key(" ")) - drive(t, a, key(" ")) - if a.at(pageHome) || a.input.String() != draft+" " { - t.Fatalf("spaces changed navigation or draft %q", draft) - } - } - for _, where := range []page{pageTasks, pageMemory, pageSearch, pageSpend, pageStanding} { - a, box := driveToPlace(t, newHomeLab(t), where) - drive(t, a, key(" ")) - drive(t, a, key(" ")) - if a.at(pageHome) { - t.Fatalf("spaces navigated from %v", where) - } - if box != nil && box.String() != " " { - t.Fatalf("spaces lost from %v", where) - } - } -} - -func TestEscapeDefersSubharnessUntilExplicitAnswer(t *testing.T) { - a := backApp(t) - a.subPage = subPage{open: true, card: &subCard{name: "pending", offer: 42}} - a.state = stateWorking - drive(t, a, key("esc")) - if a.subPage.open || !a.awaitingSubharness() { - t.Fatal("Escape answered or lost the offer") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || !a.awaitingSubharness() { - t.Fatal("pending offer prevented back navigation") - } - a.closeHome() - a.openSubharness("") - if !a.subPage.open || a.subPage.card.offer != 42 { - t.Fatal("/subharness did not resume offer") - } - a.withdrawSubharnessProposal(42, "pending") - if a.awaitingSubharness() { - t.Fatal("withdrawal left a deferred offer behind") - } -} - -func TestEscapePeelsModalLayersBeforeHome(t *testing.T) { - for _, test := range []struct { - name string - open func(*app) - showing func(*app) bool - }{ - {"model", func(a *app) { a.pick.open = true }, func(a *app) bool { return a.pick.open }}, - {"effort", func(a *app) { a.effPick.open = true }, func(a *app) bool { return a.effPick.open }}, - {"crew", func(a *app) { a.crewPick.open = true }, func(a *app) bool { return a.crewPick.open }}, - {"resume", func(a *app) { a.roster.open = true }, func(a *app) bool { return a.roster.open }}, - {"folder", func(a *app) { a.folder.open = true }, func(a *app) bool { return a.folder.open }}, - {"files", func(a *app) { a.shelf.open = true }, func(a *app) bool { return a.shelf.open }}, - {"connections", func(a *app) { a.connPanel.open = true }, func(a *app) bool { return a.connPanel.open }}, - {"harness", func(a *app) { a.harnPanel.open = true }, func(a *app) bool { return a.harnPanel.open }}, - {"permissions", func(a *app) { a.permPanel.open = true }, func(a *app) bool { return a.permPanel.open }}, - {"drafts", func(a *app) { a.draftPage.open = true }, func(a *app) bool { return a.draftPage.open }}, - {"subharness list", func(a *app) { a.subPage.open = true }, func(a *app) bool { return a.subPage.open }}, - } { - t.Run(test.name, func(t *testing.T) { - a := backApp(t) - a.input.setText("keep this draft") - test.open(a) - drive(t, a, key("esc")) - if test.showing(a) || a.at(pageHome) { - t.Fatal("Escape skipped the modal's parent") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || a.input.String() != "keep this draft" { - t.Fatal("Escape failed to settle on Home with the draft") - } - }) - } -} - -func TestEscapeFromTaskRoomReachesHomeWithoutStoppingTheTask(t *testing.T) { - a, agent, _ := roomApp(t) - door := backApp(t) - a.open, a.resume = door.open, door.resume - clickRail(t, a, 0) - a.input.setText("a task correction") - drive(t, a, key("esc")) - if a.roomOpen() { - t.Fatal("Escape stayed in room") - } - for range 4 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || agent.stops != 0 { - t.Fatal("Escape failed to go Home without stopping") - } - a.closeHome() - clickRail(t, a, 0) - if a.input.String() != "a task correction" { - t.Fatal("Escape lost the task draft") - } -} - -func TestBackPastAnApprovalKeepsItUnansweredAndReopenable(t *testing.T) { - agent, a := wired([]session.Event{ - toolBegin("edit", "edit main.go"), - consentEvent(3, "edit", "edit main.go", `tool "edit"`), - }) - door := backApp(t) - a.open, a.resume = door.open, door.resume - typeLine(t, a, "fix it") - settleAsk(a) - for range 5 { - drive(t, a, key("esc")) - } - if !a.at(pageHome) || !a.asking() || len(agent.answers) != 0 || agent.stops != 0 { - t.Fatal("back navigation answered or stopped the pending question") - } - a.closeHome() - drive(t, a, tea.KeyPressMsg{Code: 'y', Mod: tea.ModAlt}) - if !a.questioning() { - t.Fatal("deferred question could not be reopened") - } -} diff --git a/internal/tui3/background.go b/internal/tui3/background.go index f15f7ef0d..d8b162180 100644 --- a/internal/tui3/background.go +++ b/internal/tui3/background.go @@ -4,7 +4,7 @@ package tui3 // // Watching `go test ./...` grind through minute three used to leave a person // two choices, and both of them threw the work away: keep watching until the -// harness's own timeout killed it, or press ctrl+c, which killed it sooner. There +// harness's own timeout killed it, or press esc, which killed it sooner. There // was no third answer, because there was nothing under the surface that could // take a running process and keep it. // @@ -17,7 +17,7 @@ package tui3 // // ── THE KEY, AND WHY THIS ONE ── // -// ctrl+b is copy mode and ctrl+c is the interrupt, and neither is for sale. ctrl+g +// ctrl+b is copy mode and esc is the interrupt, and neither is for sale. ctrl+g // already closes and restores the task column; while a foreground command can // be kept, this reading wins, and with none the column keeps the key. It is // plain BEL so every terminal on every platform delivers it, and it needs no diff --git a/internal/tui3/background_test.go b/internal/tui3/background_test.go index bc3cb7afe..ca02b3c8e 100644 --- a/internal/tui3/background_test.go +++ b/internal/tui3/background_test.go @@ -73,7 +73,7 @@ func TestARunningCommandIsSentToTheBackgroundWithOneKey(t *testing.T) { drive(t, a, tea.KeyboardEnhancementsMsg{Flags: 1}) typeInto(t, a, "use the race-safe helper") wantHint := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + - " · ctrl+g backgrounds · ctrl+c interrupt" + " · ctrl+g backgrounds · esc interrupt" if got := a.hintWord(); got != wantHint { t.Fatalf("the full running-turn hint is %q, want %q", got, wantHint) } diff --git a/internal/tui3/bargein.go b/internal/tui3/bargein.go index 87a4ee8fe..2abea40fe 100644 --- a/internal/tui3/bargein.go +++ b/internal/tui3/bargein.go @@ -83,7 +83,7 @@ import ( const bargeKey = "ctrl+shift+enter" // bargeSendWord is what this gesture does in the running-turn hint. It belongs -// to ctrl+shift+enter alone: ctrl+c stops and clears waiting queues, while this chord +// to ctrl+shift+enter alone: esc stops and clears waiting queues, while this chord // deliberately preserves the draft it just parked so the stream close sends it. const bargeSendWord = "stops and sends" diff --git a/internal/tui3/bargein_test.go b/internal/tui3/bargein_test.go index c5e8ba459..460458545 100644 --- a/internal/tui3/bargein_test.go +++ b/internal/tui3/bargein_test.go @@ -183,7 +183,7 @@ func TestTheChordIsAbsentOnATerminalThatCannotSpellIt(t *testing.T) { if a.bargeOffered() { t.Fatal("the chord is offered on a terminal that never said it could send it") } - if got := a.hintWord(); got != steerShortHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != steerShortHint+" · esc interrupt" { t.Fatalf("hint = %q, want the plain-enter steer where the chord cannot work", got) } if strings.Contains(plain(frame(a)), bargeKey) { @@ -213,7 +213,7 @@ func TestTheHintTeachesBothMeaningsOnlyWhileThereIsSomethingToSend(t *testing.T) // A running turn with an EMPTY box: nothing to send, so the slot keeps the // plain interrupt. This is the emptiness law on the line itself. - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("an empty box while working = %q, want the plain interrupt", got) } diff --git a/internal/tui3/boxseam_test.go b/internal/tui3/boxseam_test.go index d6d11dc8b..5fd322384 100644 --- a/internal/tui3/boxseam_test.go +++ b/internal/tui3/boxseam_test.go @@ -340,7 +340,7 @@ func TestConversationControlsMatchHomeAndKeepTheHomeDoor(t *testing.T) { a.chords.meta = chordMetaWord a.notices.enabled = false a.branch = "dev" - want := "opt+e effort · opt+a approvals · opt+k chats · / commands · esc home" + want := "opt+e effort · opt+a approvals · opt+k chats · / commands · space space home" if got := a.footHint(200); got != want { t.Fatalf("conversation controls = %q, want %q", got, want) } @@ -369,9 +369,7 @@ func TestConversationControlsMatchHomeAndKeepTheHomeDoor(t *testing.T) { if got := ansi.Cut(line, a.homeDoor.from, a.homeDoor.to); got != homeDoorWord { t.Fatalf("home hit target covers %q at %d columns: %q", got, width, line) } - cmd, took := a.homeDoorPress(a.homeDoor.from, row) - drive(t, a, runCmd(cmd)...) - if !took || !a.at(pageHome) { + if _, took := a.homeDoorPress(a.homeDoor.from, row); !took || !a.at(pageHome) { t.Fatalf("home hint did not open home at %d columns", width) } a.closeHome() diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index c2e611765..a29487d9e 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -2254,10 +2254,10 @@ func TestTheHintSlotFollowsTheStateAndIsEmptyAtRest(t *testing.T) { } a.state = stateWorking - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("a working surface offered %q", got) } - if line := plain(a.hintRow(120)); !strings.Contains(line, "ctrl+c interrupt") || + if line := plain(a.hintRow(120)); !strings.Contains(line, "esc interrupt") || strings.Contains(line, microcopy) { t.Fatalf("the hint did not take the slot: %q", line) } @@ -3316,7 +3316,7 @@ func TestEscFoldsTheProposalRatherThanDecliningItOrTheTurn(t *testing.T) { t.Fatalf("esc answered the proposal: %+v", agent.answered) } if agent.stops != 0 { - t.Fatal("ctrl+c interrupted the turn") + t.Fatal("esc interrupted the turn") } if !a.awaitingTask() || a.questionCount() != 1 { t.Fatalf("esc closed the proposal: awaiting=%v open=%d", a.awaitingTask(), a.questionCount()) diff --git a/internal/tui3/chatnotes_test.go b/internal/tui3/chatnotes_test.go index 5c797f132..e40e8b6de 100644 --- a/internal/tui3/chatnotes_test.go +++ b/internal/tui3/chatnotes_test.go @@ -176,7 +176,7 @@ func TestASurfaceNoteNeverReadsAsTheModelsNextBullet(t *testing.T) { entry{kind: entryAssistant, settled: true, text: "Try these:\n\n- one\n- two\n- three"}, ) a.note("3 standing orders here — /standing") - a.note("esc back · ctrl+c interrupts or quits") + a.note("esc interrupts · ctrl+c quits") a.touch() var drawn []string @@ -190,7 +190,7 @@ func TestASurfaceNoteNeverReadsAsTheModelsNextBullet(t *testing.T) { bullet = i case strings.Contains(line, "3 standing orders here"): first = i - case strings.Contains(line, "ctrl+c interrupts"): + case strings.Contains(line, "esc interrupts"): second = i } } diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index ea9bd8aea..f5919e623 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -150,7 +150,7 @@ var commands = []command{ // pick a point out of (rewindsheet.go) — and then the gesture that takes the // last message back without opening anything (rewind.go). Two tiers, one row, // in the order a person meets them. - {name: "rewind", desc: "go back to an earlier point", alias: []string{"undo", "back"}}, + {name: "rewind", desc: "go back to an earlier point · esc esc takes back the last", alias: []string{"undo", "back"}}, // WHAT HAS ALREADY BEEN ANSWERED, and the way to take one back // (permissions.go). It BELONGS beside /settings and /connect — those two are // "what may this thing do" and "what may it reach", and this is "what has it @@ -276,7 +276,6 @@ var commands = []command{ {name: "effort", desc: "how hard this conversation thinks · the five rungs, and what each buys", alias: []string{"think", "thinking"}}, {name: "effort", args: "<rung>", desc: "…set it outright · " + effortKey + " walks it, or press it on the seam"}, - {name: "ask", args: "<question>", desc: "ask here on home", door: sendDoorAsk}, {name: "task", args: "<brief>", desc: "start work you can walk away from", door: sendDoorTask}, {name: "task", args: "solo <brief>", desc: "…with one worker, and no sizing call before it", door: sendDoorTask}, // THE THIRD ROW IS GONE, AND ITS ABSENCE IS THE FEATURE. It typed @@ -977,7 +976,7 @@ func helpText(file string, chords chordSpelling) string { // THE DOOR IS NAMED HERE BECAUSE ONE KEY CARRIES TWO MEANINGS // (leaving.go): at rest it leaves, mid-turn it stops the model, and a // person whose ctrl+c "only interrupted" looks here before anywhere else. - "ctrl+c quits everything · mid-turn it interrupts instead", + "ctrl+c quits everything · mid-turn it interrupts instead, like esc", // tab is the seventeenth rung of the key router (input.go) and does // nothing at all when this terminal holds one conversation — which is // why the line says what it needs rather than promising it always works. @@ -1081,7 +1080,7 @@ func helpText(file string, chords chordSpelling) string { // the machine, and one word meaning two places on the same list is a // person pressing ← ← to find out where they end up. "← ← out of a task room · the conversation, at the live edge", - "esc back one layer · home when no layer remains · /home", + "space space over an empty box: home · /home · esc back", // THE WORD KILL IS NAMED BY THE KEYS THAT STILL REACH THE BOX. ctrl+w was // on this row until it became the close-tab chord above, and a sheet that // went on offering it would be teaching a keystroke that shuts the window diff --git a/internal/tui3/escword_test.go b/internal/tui3/escword_test.go index 3c9d1fe48..cd9662c01 100644 --- a/internal/tui3/escword_test.go +++ b/internal/tui3/escword_test.go @@ -52,7 +52,7 @@ func TestTheKeySheetSpellsTheEscapeGestureOneWay(t *testing.T) { {"the task roster", railHoldChord + " ", "esc back"}, {"the new chat", newChatChord + " ", "esc back"}, {"the conversation switcher", hopOpenKey + " ", "esc cancel"}, - {"back navigation", "esc back", "home when no layer remains"}, + {"space space, over an empty box", "space space", "esc back"}, } { found := "" for _, line := range strings.Split(sheet, "\n") { diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 84f5272a5..682fefb98 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -11,7 +11,7 @@ import ( // // ─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s project: ~/src/parser ─ // › your sentence -// alt+e effort · alt+a approvals · alt+k chats · / commands · esc back +// alt+e effort · alt+a approvals · alt+k chats · / commands · space space home // // THE SEAM IS WHAT ANSWERS AND HOW MUCH. The rule above the box carries the // model answering the conversation on the left, and the numbers on the right diff --git a/internal/tui3/footswap.go b/internal/tui3/footswap.go index fec3ba630..096d2f4bc 100644 --- a/internal/tui3/footswap.go +++ b/internal/tui3/footswap.go @@ -11,7 +11,7 @@ import ( // Until 2026-09-17 the seam over the box carried the keys that work right now // on its right, and the row under the box carried the numbers: // -// ─ porting the parser · glm-5.3-flash:high · ◇ asks ──── esc back · / commands ─ +// ─ porting the parser · glm-5.3-flash:high · ◇ asks ──── space space home · / commands ─ // › your sentence // $0.27 · ⟲ saved $0.0038 · 58% cached 66.8k/1.3M · 5% 38 tok/s · ⠹ working · 12s // @@ -22,7 +22,7 @@ import ( // // ─ glm-5.3-flash (deepinfra):high · ◇ asks ── $0.27 · 58% cached 66.8k/1.3M · 5% ⠹ working · 12s ─ // › your sentence -// alt+e effort · alt+a approvals · alt+k chats · / commands · esc back +// alt+e effort · alt+a approvals · alt+k chats · / commands · space space home // // ─ glm-5.3-flash:auto · ◇ asks ───────────────── project: ~/codeaf ─ // › type to search or start something new @@ -240,9 +240,9 @@ func (a *app) hintRow(width int) string { } else { hint = "" } - if offset := strings.Index(hint, a.escapeDoorWord()); offset >= 0 { + if offset := strings.Index(hint, homeDoorWord); offset >= 0 { from := 1 + ansi.StringWidth(hint[:offset]) - a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(a.escapeDoorWord())} + a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(homeDoorWord)} } // THE ROW FILLS THE FRAME, as every foot row does: a row shorter than the // frame would leave the cells behind it to whatever the last frame drew. diff --git a/internal/tui3/home.go b/internal/tui3/home.go index de8200e62..08c3b1e07 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -355,7 +356,17 @@ const ( // under a dim rule — and everything under that rule is as reachable as // everything above it. homeElsewhereWord = "elsewhere" - // homeRunWord identifies command drafts for the dispatcher. + // homeStartWord is the action row's label, with what was typed quoted after + // it. "conversation" and not "chat" because that is what this surface calls + // one everywhere else it names one — /new closes a session and starts a + // fresh one, and the manual has said "conversation" since before home + // existed. + homeStartWord = "start a new conversation" + // homeRunWord is what that same row says instead when the box holds a + // COMMAND rather than a sentence. Enter dispatches a "/" line and never + // sends it ([app.homeEnter]), so a row still offering to start a + // conversation with it would be the one row on this screen that names the + // wrong key's meaning — see [homeView.runLabel]. homeRunWord = "run" // homeStartGlyph marks it. A plain `+` on purpose: it is the one row on the // column that is not a thing that exists yet, and every other glyph here is @@ -415,6 +426,27 @@ const ( // the same. A line that says work is being hidden and cannot be asked to // stop hiding it is a dead end somebody hits and gives up at. homeQuiet + // homeAction is "start a new conversation", drawn only while something is + // typed and always at the very BOTTOM of the list. It is a cursor stop and + // it is where the cursor RESTS by default, which is what keeps type-and-enter + // meaning exactly what it meant before the box could also search. + // + // IT USED TO LEAD THE LIST, AND THAT SPLIT A PERSON'S ATTENTION IN TWO. The + // characters appear in the box at the FOOT of the frame, and the row that + // says what enter will do with them stood at the TOP — so typing made the eye + // jump between the two far ends of the screen, and the cursor was up at one + // end while the caret blinked at the other. Everything about typing now + // clusters at the foot: the box, the row directly above it, and the hint line + // under it, with the matches growing UPWARD above them. It is the drop-up the + // command list and the "@" list already are (render.go's overlay), which is + // what this screen should have been from the start — a list that rises out of + // the thing you are typing into. + // + // IT IS ALSO THE ONLY THING THAT MOVES THE LIST. With nothing typed home is + // a dashboard hanging from the top of its region and this row does not exist; + // the first character brings it into being at the foot and lifts the list to + // meet it (the block above [homeView.buildWorld] states both halves). + homeAction // homeBlank is the empty line between projects. homeBlank // homeItem is ONE STANDING ITEM — a reminder, a watch, a rule, an overnight @@ -952,7 +984,7 @@ func (a *app) raiseHome() tea.Cmd { // // BEING GREETED BY HOME AND BEING ABLE TO GO THERE ARE TWO QUESTIONS, and // this condition answers only the first. The door from inside the -// conversation — `esc`, `/home`, the advertisement at the foot — is +// conversation — `space space`, `/home`, the advertisement at the foot — is // open on every machine home can read at all ([app.homeDoorOpen]), and an // empty home is a designed screen rather than a refusal ([homeEmptyRow]). // What this condition decides is whether that screen is put in front of a @@ -968,7 +1000,7 @@ func (a *app) landHome() { // call down the wire has come back, so the world here is not an answer yet // ([app.worldKnown]) — there is nothing to decide "is there work elsewhere" // from, and a greeting that waited on a round trip would be a launch that - // waited on a round trip. `esc` opens the same screen a moment later, + // waited on a round trip. `space space` opens the same screen a moment later, // with the far machine's rows on it. if a.hosted() || !a.canOpen() { return @@ -1436,7 +1468,7 @@ func (h *homeView) build() { previousLine, hadLine := h.focusedLine() previousCommand, previousQuery := h.cmd.open, h.cmd.query // An empty box is not a choice anybody has made yet, so the next character - // typed belongs to the composer again. + // typed starts on the action row again. if !h.searching() { h.picked = false } @@ -1450,8 +1482,10 @@ func (h *homeView) build() { // goes to the top of the new list, and comes back to the row it was on if // that row is still in it. // - // COMPOSING HAS NO SELECTED RESULT. Enter submits the draft until the - // person explicitly selects a match with the keyboard or pointer. + // THE ACTION ROW IS THE EXCEPTION AND IT IS THE WHOLE POINT. While something + // is typed, the cursor rests on "start a new conversation" unless the person + // walked off it — so type-and-enter still starts a chat, exactly as it did + // before this box could also search (see [homeAction]). h.cursor, h.top = h.clamp(0), 0 if h.cmd.open { // Commands share the conversation menu's initial selection. An @@ -1464,10 +1498,22 @@ func (h *homeView) build() { return } if h.searching() { - // Keep an explicitly chosen result through filtering and idle refreshes. + // THE ROW A PERSON WALKED ONTO IS THE ROW THEY ARE STILL ON, and it does + // not have to be a conversation. `picked` is the decision to stop writing + // and start choosing ([homeView.move] states it), and the question asked + // here used to be the narrower "is that CONVERSATION still on the list" — + // which is false for every other row the drop-up offers, so a cursor + // resting on a place or a command was forgotten by every rebuild. The slow + // tick rebuilds three seconds at a time ([app.refreshHome]), so a person + // who had stopped typing and touched nothing watched the selection walk + // back down to the action row on its own, over and over. h.picked = h.picked && hadLine && h.pointSame(previousLine) if !h.picked { - h.pointComposer() + // AND THE ACTION ROW IS AT THE BOTTOM NOW, so resting on it is no + // longer the same thing as resting at the top of the list ([homeAction] + // says why it moved). It is found rather than counted to: how many rows + // a query left above it is not a number this function knows. + h.pointAction() } // Either way the cursor is where it belongs: [homeView.pointSame] put it // back on the row that was chosen, and the followers below are about a @@ -1547,15 +1593,23 @@ func (h *homeView) pointItem(id string) { }) } -// pointComposer leaves the results unselected while the composer owns Enter. -// The position below the last result lets one up-arrow select that result. -func (h *homeView) pointComposer() { h.cursor = len(h.lines) } +// pointAction puts the cursor on "start a new conversation", which is the last +// line of the list whenever there is one at all. +func (h *homeView) pointAction() { + for at, line := range h.lines { + if line.kind == homeAction { + h.cursor = at + return + } + } +} // dropUp reports whether the list is drawn as a DROP-UP: its bottom row against // the box at the foot, and the matches rising above it. // -// It is exactly "something is typed", because a person is then looking at -// the box rather than reading down a roster. With nothing typed home is a dashboard +// It is exactly "something is typed", because that is exactly when the action +// row exists ([homeView.buildWorld]) and exactly when a person is looking at the +// box rather than reading down a roster. With nothing typed home is a dashboard // somebody is reading, and it hangs from the top like every other list here. func (h *homeView) dropUp() bool { return h.searching() } @@ -1583,8 +1637,10 @@ func (h *homeView) dropUp() bool { return h.searching() } // - Nothing is lifted, so the frame reads top-down as a page of everything // this machine holds, which is the one thing this surface is for. // -// WHILE SOMETHING IS TYPED the matches rise out of the box, with the best -// result nearest the seam. Clearing the box puts the dashboard back. +// WHILE SOMETHING IS TYPED it becomes a drop-up: the action row is appended as +// the list's last line, [homeLift] pushes the whole column down so that row +// lands against the box, and the matches rise above it ([homeAction] carries +// the defect that bought that). Clearing the box puts the dashboard back. // // AND IN THAT SHAPE THE RANKING IS DRAWN UPSIDE-DOWN, which is the one thing // about the drop-up that is not simply the dashboard moved. A ranked list read @@ -1598,9 +1654,28 @@ func (h *homeView) dropUp() bool { return h.searching() } // // THE FIRST MATCH THE WALK REACHES IS THE TOP-RANKED ONE. // -// One up-arrow selects the best match. Further up-arrows reach weaker ones; -// down past the last result returns to composing. Project headings remain -// above their own rows. +// It is the SECOND ↑ and not the first, because `ask here` sits between the +// action row and the matches (homeexchange.go): the two rows that do something +// with the SENTENCE are one cluster against the box, and the rows that are other +// conversations begin above them. Further ↑ walks into progressively weaker ones +// and ↓ comes back toward the box, which is the same grammar the action row +// already had. A project's heading still sits ABOVE its own rows: sections stack +// by rank and the rows inside one do too, but a name drawn under the things it +// names reads upside-down. +// +// AND THE CONVERSATION THIS WINDOW IS IN MAY NOT BE ON THE LIST AT ALL. A +// session folder nobody has spoken in yet is not a row the world reports +// (session's readSessionRow drops one whose meta names it but records no +// message), and a launch that home GREETS is exactly that folder — so +// [homeView.point] finds nothing to point at and the cursor stays where +// [homeView.clamp] left it, on the first conversation of the first project. +// That is the honest place for it, and the thing that matters is that it is a +// CONVERSATION: the card beside it is drawn from the row under the cursor and +// draws nothing for a heading, a fold line or the action row, so a cursor +// resting anywhere but a conversation is a resting home with half its screen +// empty. That is precisely what shipped, and +// [TestAFreshLaunchStillRestsOnAConversationWithItsCard] is the pin that keeps +// it from shipping twice. func (h *homeView) buildWorld() { commandRows := h.commandLines() if h.cmd.open { @@ -1689,9 +1764,25 @@ func (h *homeView) buildWorld() { }) h.projectBlock(hit, query) } - // Only matches belong above the seam. Submission is the composer's own - // action, so it does not compete with the results for a row or a cursor. + // THE ACTION ROW CLOSES THE LIST, directly above the box the words were + // typed into ([homeAction] says why it is not at the top any more). It is + // separated from the matches by the same blank line that separates two + // projects, because it is not one of them: everything above it exists, and + // it is the one row that is a thing that does not. + h.blank() + // AND `ask here` SITS DIRECTLY ON TOP OF IT, with no blank between them, + // because the two rows are one cluster: they are the two things enter can + // do with the same characters, and a gap would read as two unrelated + // offers. The cursor still RESTS on `start a new conversation` — typing and + // pressing enter means today what it meant yesterday — and this row is the + // one ↑ that asks the sentence instead of opening a conversation for it + // (homeexchange.go). + // AND THE PLACES THE WORDS MATCH SIT DIRECTLY OVER THAT CLUSTER, which + // in a drop-up is the top of the results: a place ranks first when the + // words match it, so it is the row nearest what somebody is reading + // upward from (homeplaces.go). h.lines = append(h.lines, h.placeLines(query)...) + h.lines = append(h.lines, homeLine{kind: homeAskHere}, homeLine{kind: homeAction}) } // homeHit is one project and the conversations of it that survived the box. @@ -1959,6 +2050,8 @@ func (l homeLine) sameRow(other homeLine) bool { // cursor never rests on one (homepanel_spend.go). case homeLedger, homeReadout, homePhoneNews, homePhoneMore: return l.project != "" && l.project == other.project && l.dir == other.dir + case homeAction, homeAskHere: + return true } return false } @@ -2243,7 +2336,7 @@ func (l homeLine) stop() bool { switch l.kind { // A PROJECT'S ROW IS READ AND NOT STOOD ON (owner, 2026-09-17), like spend's // lines: the rail holds nothing a cursor may rest on (homepanel_projects.go). - case homeSession, homeQuiet, homeItem, homeItemFold, + case homeSession, homeQuiet, homeAction, homeItem, homeItemFold, homeAskHere, homeProject, homeExchangeRow, homeFold: return true // the router's lane: an offered place is a door like every other door on this @@ -2298,15 +2391,17 @@ func (h *homeView) move(delta int) { break } if next >= len(h.lines) { - if h.searching() && !h.cmd.open { - at = len(h.lines) - } break } at = next } h.cursor = at - h.picked = h.cursor >= 0 && h.cursor < len(h.lines) + // WALKING OFF THE ACTION ROW IS THE DECISION. Until it is made the box is a + // message being written; after it, the person is picking from the list and + // the cursor stays where they put it through every further keystroke. + if h.cursor >= 0 && h.cursor < len(h.lines) && h.lines[h.cursor].kind != homeAction { + h.picked = true + } } // ── the keyboard ──────────────────────────────────────────────────────────── @@ -2477,15 +2572,23 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { // circle, unrolled onto the two keys that already point the way"), and the // errand in the pane is taken into with `→` from its own row. case "esc": - // Home is the final back destination. Dismissing a command list keeps - // the draft, just as it does in a conversation. + // ONE LAYER AT A TIME, the settings panel's rule: a box with something + // in it is cleared first, and the second esc leaves. A person who typed + // a search and meant to keep looking must not be thrown back into the + // conversation for pressing the key that means "undo that". + // + // A REQUEST OUT ON THE DISK IS THE INNERMOST LAYER OF ALL, because it is + // the only one that is doing something to another window while it stands + // (takeover.go's [app.cancelTakeover]). if a.cancelTakeover() { return nil } - if h.cmd.open { - h.cmd.dismiss(h.cmd.at) + if !h.box.empty() { + h.box.reset() h.build() + return nil } + a.closeHome() return nil // THE FOUR KEYS THAT MOVE THE CURSOR ASK FOR NOTHING HERE. What the card @@ -2910,11 +3013,7 @@ func (h *homeView) rebuild() { h.lines = h.lines[:0] // phone lane: at [tierPhone] the column is an inbox (homephone.go). h.buildFor() - if h.searching() && !h.picked { - h.pointComposer() - } else { - h.cursor = h.clamp(h.cursor) - } + h.cursor = h.clamp(h.cursor) } // buildFor is which SHAPE the column takes, and it is asked in the two places @@ -2941,7 +3040,8 @@ func (h *homeView) buildFor() { // THE SWITCHER IS THE RESTING SHAPE AND THE DROP-UP IS THE TYPED ONE // (place_home.go). With nothing in the box this screen is one flat ranked // list of everything on the machine; the first character makes it the - // ranked-by-[homeRank] drop-up, with the best match against the box. + // ranked-by-[homeRank] drop-up it has always been, with `ask here` and the + // action row against the box. if h.searching() { h.buildWorld() return @@ -2949,8 +3049,7 @@ func (h *homeView) buildFor() { h.buildGrid() } -// homeSubmit chooses the submission door without introducing a selectable -// action row among the search results. +// homeSubmit runs the draft selected by the new-conversation row or a send tag. func (a *app) homeSubmit() tea.Cmd { h := &a.home typed := strings.TrimSpace(h.box.String()) @@ -2975,12 +3074,6 @@ func (a *app) homeSubmit() tea.Cmd { h.say(slashTagRefusal, "") return nil } - if len(tags) == 1 { - tag := tags[0] - if commandDoor(string(h.box.value[tag.from+1:tag.to])) == sendDoorAsk { - return a.runAskCommand(removeSlashTag(h.box.value, tag)) - } - } return a.homeStart(typed) } @@ -3024,6 +3117,8 @@ func (a *app) homeEnter() tea.Cmd { return nil } switch line.kind { + case homeAction: + return a.homeSubmit() case homePlace: // ENTER GOES THERE, AND GOING TO A PLACE LEAVES YOU THERE (SCREEN 1g). // The box is not cleared on the way — the sentence is the person's, and @@ -3040,6 +3135,9 @@ func (a *app) homeEnter() tea.Cmd { // says — run it bare, or hold the box for the words it takes // (homeslash.go's [app.homeRunCommand]). return a.homeRunCommand(line) + case homeAskHere: + // The same sentence, asked rather than opened (homeexchange.go). + return a.askHere(strings.TrimSpace(h.box.String())) case homeExchangeRow: // ENTER ON AN ERRAND HANDS IT THE KEYBOARD. There is nothing to open — // the exchange is already drawn beside the row, or on a narrow frame is @@ -3630,8 +3728,32 @@ func (h *homeView) typedPlace(text string) string { return "" } -// runLabel reports whether the draft is a command, keeping pasted paths -// literal while preserving the existing slash dispatcher. +// startLabel is what the action row says enter will do, which on a screen where +// enter has two possible meanings must be legible without looking away from the +// list. +func (h *homeView) startLabel() string { + text := strings.TrimSpace(h.box.String()) + if text == "" { + return homeStartWord + } + if place := h.pastedProject(); place != "" { + return homeStartWord + " in " + place + } + // THE SAME QUESTION IN THE SAME ORDER [app.homeEnter] ASKS IT, which is the + // whole reason this label exists: a row that ranked the two readings of a + // leading slash differently from the key would be wrong about the one line + // it is there to be right about. + if word := h.runLabel(text); word != "" { + return word + } + return homeStartWord + ": " + strconv.Quote(text) +} + +// runLabel is the action row's label when what is typed is a COMMAND, and "" for +// everything else — which makes it the one question `is this line a command`, +// asked by the label, by the foot and by enter itself so that the three cannot +// come to disagree ([app.homeEnter], [app.homeHintWords], homephone.go's narrow +// column). // // A LEADING SLASH IS NOT ENOUGH, because `/tmp/alpha` is a place. The two are // separated in THE ORDER THE DISPATCHER ITSELF SEPARATES THEM (app.go's @@ -3754,26 +3876,120 @@ func homeBucketOf(transcript string) string { // homeDoorWord is the dim advertisement at the foot of an idle conversation, // and it is written in the hint slot's own grammar — the key, then the noun, // exactly as `ctrl+g tasks` is (render.go's [app.hintWord]). -const homeDoorWord = "esc home" - -// The footer names the destination of Escape at this depth. -func (a *app) escapeDoorWord() string { - if a.roomOpen() { - return "esc main" +const homeDoorWord = "space space home" + +// homeGesture is TWO SPACES TYPED INTO AN EMPTY BOX, and it is the way back to +// home — from inside a conversation, and from every place standing over it. +// +// WHY A GESTURE AND NOT A KEY. Every ctrl+letter is taken. `esc` was the +// obvious candidate and is not available: on an idle conversation it already +// arms rewind (the hint slot says `esc again to rewind`) and it already drops a +// message parked against a turn that has ended, and a third meaning on one key +// in that state is how a surface becomes unpredictable. What was left is a +// gesture, and a leading run of spaces in an empty message is the one keystroke +// on this surface that is reliably NOTHING: a message that begins with two +// spaces is a message nobody meant to send that way. +// +// THE INTERMEDIATE SPACE IS REAL, AND THAT IS THE POINT. The first space types +// itself, plainly, the way every other character does — there is no pending +// state, no timer, and no ghost. The SECOND one, arriving to find a box that +// still SHOWS nothing with that space behind the caret, takes the whole draft +// away and opens home. So somebody who genuinely wanted a leading space types it +// and carries on: space then `x` leaves ` x`, untouched, because the gesture only +// ever fires on a space and only ever over a box with no words in it. +// +// WHEREVER THE DOOR IS ADVERTISED, TWO SPACES OPEN IT — and the two halves used +// to disagree, which is the bug this asks [editor.empty] rather than counting +// runes. The foot draws `space space home` whenever the box holds nothing a +// person would call text ([app.homeDoorShowing]), and the gesture demanded a box +// holding EXACTLY one space. Every draft the two disagreed about was a door +// drawn over a gesture that could not fire — and one of them is easy to land in +// and impossible to see: `ctrl+enter` and `shift+enter` (standmark.go, +// bargein.go) arrive as a bare `ctrl+j` on every terminal that cannot spell +// them, and `ctrl+j` opens a line (input.go). Two of those on an empty box left +// `\n\n` in it, the frame drew an empty box over an advertised door, and the +// chord was dead in that conversation for good — [writeDraft] kept the invisible +// draft and the next window on the directory adopted it (draft.go). +// +// THE CARET IS WHAT "THE SPACE YOU JUST TYPED" MEANS, rather than the end of the +// draft: a space typed at the FRONT of a box holding a blank line is the same +// two keystrokes against the same blank-looking box as one typed after it. +// +// PASTED TEXT CANNOT FIRE IT. A bracketed paste arrives as its own message and +// never reaches this router at all, and a paste whose brackets leak is absorbed +// key by key into the bracket's buffer above it (app.go's [app.pasteKey]) — +// so two spaces at the start of pasted text are two characters, not a door. The +// one hole is a terminal that does not speak bracketed paste at all, where a +// paste IS a stream of keystrokes and there is nothing anywhere in this program +// that can tell it from typing. +// +// A RUNNING TURN IS NO OBSTACLE. Home takes the frame the way the settings +// panel does, and the settings panel does not disturb a turn: the stream events +// are their own messages and land whatever is drawn over them (app.go's +// Update). The turn goes on underneath and is still there when esc comes back. +func (a *app) homeGesture(msg tea.KeyPressMsg) bool { + if !a.homeDoorOpen() { + return false } - return homeDoorWord + return a.homeDoorArmed(&a.input, msg) } -// homeDoorOpen reports whether the Home destination can be opened here. +// homeDoorArmed is the part of the door that is about THE BOX, asked the same +// way whichever box the press landed in — the conversation's draft +// ([app.homeGesture]) or the box the standing place types into +// ([app.placeHomeGesture]). A door with two laws about emptiness would be a +// door that behaved differently depending on which room a person was standing +// in, which is exactly what this keeps from happening. +// +// The law is the one [app.homeGesture] always kept: a space, a box that shows +// nothing ([editor.empty]), and the space the person just typed behind the +// caret. It answers for a nil box as well, because a place with no box has no +// door — there is nothing to type two spaces into. +func (a *app) homeDoorArmed(box *editor, msg tea.KeyPressMsg) bool { + if msg.Key().Text != " " || box == nil { + return false + } + return box.empty() && box.cursor > 0 && + box.value[box.cursor-1] == ' ' +} + +// homeDoorOpen reports whether home is reachable from where this keypress is +// standing — any place or any conversation, except home itself, where the +// gesture is a no-op and the foot draws no door. It is the gesture's guard and +// the advertisement's condition, which is deliberate: a door that is drawn is +// a door that works. +// +// HOME IS ALWAYS REACHABLE, AND AN EMPTY HOME IS A SCREEN. This used to ask one +// more thing — that the machine held a conversation other than this one — on +// the argument that a door which opened on nothing should be neither drawn nor +// bound. That argument confused two questions. Whether home should GREET a +// launch that has nowhere else to go is [app.landHome]'s, and it still says no. +// Whether a person who asks for home should get it is this one's, and the +// answer is yes on any machine home can read: a fresh machine gets the same +// head, columns and foot as a full one, with this conversation's row under its +// project and `nothing here yet` where the rest will be ([homeEmptyRow]). +// A gesture that silently typed two spaces on the one day a person first tried +// it was the surface teaching them the door does not exist. +// +// The one condition left is about the machine, not its contents: the surface has +// a disk to read ([app.canOpen]). +// +// --host USED TO BE A SECOND CONDITION AND IS NOT ONE ANY MORE. Home refused +// over --host, so a door onto a refusal was correctly kept shut; then it opened +// with one sentence where its rows would be; and it now opens on THE FAR +// MACHINE'S OWN PROJECTS ([app.readWorld]) with that machine's name at the right +// end of the tab bar. A gesture that worked from `alt+1` and not from two spaces +// would be the surface teaching two different answers to one question. func (a *app) homeDoorOpen() bool { return a.canOpen() && !a.at(pageHome) } // homeDoorShowing reports whether the foot of the conversation should advertise -// it: Home is reachable and no copy or rewind mode owns the foot. The draft -// may contain words because back navigation preserves them. +// it: the door is open, and the box is EMPTY. It vanishes on the first +// character typed, because it is a door and not chrome — the space it takes is +// the keys row's, which the frame already has (render.go's [app.footHint]). func (a *app) homeDoorShowing() bool { - return (a.roomOpen() || a.homeDoorOpen()) && !a.copy.on && !a.rew.on + return a.homeDoorOpen() && a.input.empty() && !a.copy.on && !a.rew.on } // homeDoorPress is a click on that advertisement. @@ -3785,9 +4001,7 @@ func (a *app) homeDoorPress(x, y int) (tea.Cmd, bool) { if !ok || mark.kind != a.hintRowKind() { return nil, false } - // Re-enter through the event router so task rooms and roster focus get - // the same first refusal as a physical Escape press. - return func() tea.Msg { return tea.KeyPressMsg{Code: tea.KeyEscape} }, true + return a.openHome(), true } // ── the pointer ───────────────────────────────────────────────────────────── @@ -3867,7 +4081,9 @@ func (a *app) homePress(x, y int) tea.Cmd { // and a plain selection all leave the hand in the same place. a.homeTakeList() a.home.cursor = at - a.home.picked = true + if a.home.lines[at].kind != homeAction { + a.home.picked = true + } a.touch() // A CLICK ON A ROW IS `enter` ON IT, the one grammar every place keeps // (pages.go's [place.press]): the pointer resting is the preview, and the @@ -3893,7 +4109,7 @@ func (a *app) homePress(x, y int) tea.Cmd { // gesture nobody can take back. func homeClickSpends(line homeLine) bool { switch line.kind { - case homeCommand: + case homeAction, homeAskHere, homeCommand: return true } return false @@ -4243,7 +4459,8 @@ func (a *app) homeBody(left, right, room int, pal palette) []homeDrawn { } // THE DROP-UP LIFTS THE LIST AND LEAVES THE CARD WHERE IT IS. While something // is typed the left column hangs from the BOTTOM of the region so that its - // best match lands nearest the box at the foot. At rest it hangs from the top, because at rest this is a + // last row — the action row — lands against the box at the foot + // ([homeAction]). At rest it hangs from the top, because at rest this is a // page somebody is reading rather than a thing they are typing at (the block // above [homeView.buildWorld]). // @@ -4462,7 +4679,22 @@ func (a *app) homeLine(line homeLine, at, width int, pal palette) string { // A COMMAND, OFFERED BECAUSE THE WORDS MATCH ITS NAME OR AN ALIAS // (homeslash.go). return a.homeCommandRow(line, at, width, pal) - + case homeAskHere: + // The same shape as the action row under it and the same words quoted + // back, because they are the two readings of one sentence + // (homeexchange.go). + label := homeAskHereWord + if text := strings.TrimSpace(h.box.String()); text != "" { + label += ": " + strconv.Quote(text) + } + return overlayRow(homeAskHereGlyph+" "+label, "", at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) + case homeAction: + // It carries the words back at the person, cut to fit. The box at the + // foot holds them too, but the box is where you are typing and this is + // what enter will DO with it — and on a screen where enter has two + // possible meanings, the one it currently has must be legible without + // looking away from the list. + return overlayRow(homeStartGlyph+" "+h.startLabel(), "", at == h.cursor, false, at == h.hover && at == h.cursor, width, pal) } // OUR OWN ROWS ARE READ FROM THE AGENT AND NOT FROM THE PRESENCE FILE. The // file is written on a five-second heartbeat and believed for fifteen, which @@ -5215,10 +5447,49 @@ func (a *app) homeHintWords() string { // standing beside the column with no line saying how to reach it is the // half of the toggle nobody finds; the list's own verbs come first, // because that is the zone the hand is in. - return "↑↓ move · enter or tab answer this " + homeAskHereWord + return "↑↓ move · enter or tab answer this " + homeAskHereWord + " · esc close" } line, _ := a.home.focusedLine() switch { + case line.kind == homeAskHere: + // The row that asks rather than opens, and the chord that reaches it + // without walking up to it (homeexchange.go). + return "enter asks this here and keeps the record · ↓ start a conversation instead · esc clear" + case line.kind == homeAction: + // The THREE readings of the box, all said, because all three are true of + // what is on screen right now: enter opens a conversation for it, + // ctrl+enter asks it here (homeexchange.go), and ↑ walks into what it + // found. + // + // THE ARROW IS ↑ BECAUSE THE MATCHES ARE ABOVE. The action row is the last + // line of the list, against the box ([homeAction]), so walking into the + // results is walking up the screen — and a hint naming the other arrow + // would be this line lying about the next keystroke. It names the arrow + // and not a count, because the row it passes through on the way is the + // one named two clauses earlier. + // AND A COMMAND IS THE THIRD READING, so the foot says so rather than + // promising a conversation the key will not start. `ask here` is still + // true of a "/" line — the words can be asked about as words — so the + // clause that changes is the one that stopped being true. + // + // `ask here` IS ↑ AND NOT A CHORD ANY MORE. `ctrl+enter` is still bound + // (above) and is no longer advertised: most terminals cannot send it at + // all, and `alt+enter` — the spelling that survives everywhere — belongs + // to the task layer on every place (placekeys.go). What every terminal + // CAN do is press the arrow key, and the row is already there: `? ask + // here: "…"` sits directly above `+ start a new conversation: "…"` with + // the cursor resting on the latter (homeexchange.go), so one ↑ is the ask + // and two is the first match. A foot that went on naming a chord a hand + // cannot send was the screen advertising a key that does not exist. + // + // AND THE ORDER IS THE DROP ORDER. [hintFit] drops the clause nearest the + // way out — `↑↑ pick a match` — first, which is the right one to lose: + // ↑↓ walking a list is the key the resting foot already names (`↑↓ pick`) + // and the map names again. A wide frame still says all three. + if a.home.runLabel(strings.TrimSpace(a.home.box.String())) != "" { + return "enter runs this command · ↑ ask here · ↑↑ pick a match · esc clear" + } + return "enter starts a new conversation and sends this · ↑ ask here · ↑↑ pick a match · esc clear" case a.home.gridOn() && (line.kind == homeSession || line.kind == homeItem || line.kind == homeLedger): // ONE SENTENCE ON EVERY ROW OF THE GRID. A conversation, a standing // order, a landing, a line of news: each used to say its own thing here @@ -5234,30 +5505,30 @@ func (a *app) homeHintWords() string { // The panel's fold is a toggle and the foot says which way it will go; // the words are the ones every fold door on every place uses // (placeprose.go's [foldEnterWord]). - return foldEnterWord(!line.folded) + return foldEnterWord(!line.folded) + " · esc close" case line.kind == homeQuiet && line.folded: - return "enter or → show them" + return "enter or → show them · esc close" case line.kind == homeQuiet: - return "enter or ← fold them away" + return "enter or ← fold them away · esc close" case line.kind == homeItemFold && line.folded: - return "enter or → show them" + return "enter or → show them · esc close" case line.kind == homeItemFold: - return "enter or ← fold them away" + return "enter or ← fold them away · esc close" case line.kind == homeProject && line.folded: - return "enter or → open this project here" + return "enter or → open this project here · esc close" case line.kind == homeProject: - return "enter or ← fold this project away" + return "enter or ← fold this project away · esc close" case line.kind == homeLedger: // THE ROW SAYS WHERE IT GOES, so the hint says what the key does with it // and never repeats the name (place_home.go). - return "enter opens the place this happened in" + return "enter opens the place this happened in · esc close" case line.kind == homeItem: // THE KEYS THE CARD BESIDE IT ALREADY NAMES, said once more where the // hand is. One vocabulary, two places (homestanding.go's // [homeItemActions]). Grid rows already took the resting sentence above. - return homeItemActions + return homeItemActions + " · esc close" case a.home.searching(): - return "enter open · ↓ back to starting a new conversation" + return "enter open · ↓ back to starting a new conversation · esc clear" } // At rest only the draft controls are added by homeHint. return "" diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index 029a88be2..0648de054 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -38,8 +38,6 @@ type homeLab struct { pinned time.Time } -const homeStartWord = "start a new conversation" - func newHomeLab(t *testing.T) *homeLab { t.Helper() return &homeLab{t: t, root: t.TempDir(), work: t.TempDir()} @@ -334,8 +332,38 @@ func TestHomeTakesExactlyTheWholeFrame(t *testing.T) { } } +func TestHomeEscGoesBackToTheConversation(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("esc did not close home") + } +} + // esc peels one layer: a box with something in it is cleared before the screen // is left. +func TestHomeEscClearsTheBoxBeforeItLeaves(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + a.homeKey(key("@")) + a.homeKey(key("x")) + a.homeKey(key("esc")) + if !a.at(pageHome) { + t.Fatal("the first esc left home instead of clearing the box") + } + if !a.home.box.empty() { + t.Fatalf("the box still holds %q", a.home.box.String()) + } + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("the second esc did not close home") + } +} // THE EMPTINESS LAW. A conversation that ran nothing and spent nothing says // nothing about either. @@ -593,8 +621,10 @@ func TestHomeStopsSayingNeedsYouWhenTheWindowIsGone(t *testing.T) { // ── the omnibox ───────────────────────────────────────────────────────────── -// Typing filters the world without selecting a result or adding an action row. -func TestTypingFiltersLiveWhileTheComposerStaysTheDefault(t *testing.T) { +// TYPING DOES BOTH JOBS AT ONCE. The characters are a new conversation waiting +// to be sent AND a live query over the machine, and the cursor stays on the +// action row so that type-and-enter means exactly what it always meant. +func TestTypingFiltersLiveWhileTheActionRowStaysTheDefault(t *testing.T) { lab := newHomeLab(t) now := time.Now() mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", now) @@ -613,11 +643,11 @@ func TestTypingFiltersLiveWhileTheComposerStaysTheDefault(t *testing.T) { t.Fatalf("the query kept a conversation that does not match:\n%s", text) } line, ok := a.home.focusedLine() - if ok { - t.Fatalf("typing unexpectedly selected a result (kind %v)", line.kind) + if !ok || line.kind != homeAction { + t.Fatalf("the cursor left the action row while typing (kind %v)", line.kind) } - if strings.Contains(text, homeStartWord+`: "pricing"`) { - t.Fatalf("a removed action row was rendered:\n%s", text) + if !strings.Contains(text, homeStartWord+`: "pricing"`) { + t.Fatalf("the action row does not say what enter will do:\n%s", text) } } @@ -640,15 +670,27 @@ func TestEnterStillStartsAChatWithMatchesOnScreen(t *testing.T) { } runCmd(a.homeEnter()) if a.at(pageHome) { - t.Fatal("Enter while composing left home open") + t.Fatal("enter on the action row left home open") } if len(next.sent) != 1 || next.sent[0] != "pricing" { t.Fatalf("the new conversation was sent %v", next.sent) } } -// One up-arrow selects the best match; down returns to composing. -func TestWalkingUpFromTheComposerPicksFromTheList(t *testing.T) { +// Walking UP off the action row is the decision to pick from the list instead, +// and it sticks. +// +// IT USED TO BE ↓, and the arrow turned round with the action row. The row sits +// at the BOTTOM of the list now, against the box a person is typing into +// ([homeAction]), so the matches are above it and walking into them is walking +// up the screen. WHICH match the walk reaches is +// [TestTheBestMatchSitsNextToTheActionRow]. +// +// IT IS TWO ↑ AND NOT ONE, because `ask here` sits between the action row and +// the matches (homeexchange.go): the two rows that do something with the +// SENTENCE are one cluster against the box, and the rows that are other +// conversations begin above them. +func TestWalkingOffTheActionRowPicksFromTheList(t *testing.T) { lab := newHomeLab(t) now := time.Now() mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) @@ -658,47 +700,110 @@ func TestWalkingUpFromTheComposerPicksFromTheList(t *testing.T) { a.homeKey(key(string(r))) } a.homeKey(key("up")) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAskHere { + t.Fatalf("the first ↑ should reach `ask here` (kind %v)", line.kind) + } + a.homeKey(key("up")) if row := a.home.focused(); row.Transcript != mine { t.Fatal("↑ did not land on the match") } a.homeKey(key("i")) if row := a.home.focused(); row.Transcript != mine { - t.Fatal("typing after ↑ lost the selected match") + t.Fatal("typing after ↑ threw the cursor back to the action row") } - // One down-arrow from the nearest result returns to composing. + // And ↓ walks back down through the same two rows to the action row, which + // is where the sentence is. + a.homeKey(key("down")) a.homeKey(key("down")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not come back to the action row (kind %v)", line.kind) } } -// Results stay next to the message box without submission rows between them. +// TYPING IS ONE CLUSTER AT THE FOOT, and this pins the geometry that makes it +// one. +// +// The defect it answers: the characters landed in the box at the very bottom of +// the frame while the row saying what enter would do with them stood at the very +// top, so the eye had to jump between the two ends of the screen and the cursor +// was at one end while the caret blinked at the other. The action row now sits +// on the LAST body row — directly above the rule and the box — with the matches +// rising above it. +// +// THE RESTING SCREEN IS THE OTHER SHAPE, and [TestHomeWithNothingTypedHangsFromTheTop] +// pins it: a dashboard from the top with the preview card beside it. The lift is +// what typing does, and only what typing does. func TestTypingClustersAtTheFootOfHome(t *testing.T) { lab := newHomeLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) + lab.session("-tmp-beta", "bbbb000000000001", "pricing sheet import", "/tmp/beta", now.Add(-time.Hour)) + a := lab.app(mine) a.openHome() - typeHome(a, "pricing") + for _, r := range "pricing" { + a.homeKey(key(string(r))) + } + width, height := a.size() - rows, _, _, caretY := a.homeFrame(width, height) + lines, _, _, caretY := a.homeFrame(width, height) + rows := make([]string, len(lines)) + for i, line := range lines { + rows[i] = strings.TrimRight(ansi.Strip(line), " ") + } + action := -1 + for i, row := range rows { + if strings.Contains(row, homeStartWord+`: "pricing"`) { + action = i + } + } + if action < 0 { + t.Fatalf("the action row is not on the frame:\n%s", strings.Join(rows, "\n")) + } + // THE BOX IS THE ROW THE CARET IS ON, and the action row is three rows above + // it: the list's padding row, then the frame's own foot rule (home.go's + // [app.homeFrame] states why the list never touches that rule). Anything more + // than that is the split this test exists to stop coming back. + if caretY-action != 3 { + t.Fatalf("the action row is %d rows above the box, want 3:\n%s", caretY-action, strings.Join(rows, "\n")) + } + if !strings.Contains(rows[caretY], "pricing") { + t.Fatalf("row %d is not the box:\n%s", caretY, strings.Join(rows, "\n")) + } + // AND THE MATCHES ARE ABOVE IT, not below — the list grew upward out of the + // box rather than downward from the title. match := -1 for i, row := range rows { - plain := ansi.Strip(row) - if strings.Contains(plain, "Pricing Research") { + if strings.Contains(row, "Pricing Research") { match = i } - if strings.Contains(plain, homeStartWord) || strings.Contains(plain, "? ask here:") { - t.Fatalf("action row remains: %s", plain) - } - } - if match < 0 || caretY-match != 3 { - t.Fatalf("nearest result at %d, caret at %d; want a three-row gap", match, caretY) } - if _, ok := a.home.focusedLine(); ok { - t.Fatal("typing selected a search result") + if match < 0 || match > action { + t.Fatalf("the matches are not above the action row (match %d, action %d):\n%s", + match, action, strings.Join(rows, "\n")) } - if hint := a.homeHintWords(); strings.Contains(hint, "ask here") || strings.Contains(hint, "starts a new") { - t.Fatalf("submission hint remains: %s", hint) + // The hint under the box names the arrow that is actually true of the screen — + // ↑, because the matches rise ABOVE the action row the caret sits against. + // + // IT IS ASKED OF THE SENTENCE AND NOT OF THE DRAWN ROW, and that is not a + // weaker question. The foot is a hundred and fourteen cells with the router's + // keys on it and this frame is a hundred wide, so [hintFit] drops the clause + // nearest the way out to make it fit — by design, and the ladder it drops down + // is pinned by [TestAHintDropsWholeClausesAndKeepsTheWayOut]. Asked of the + // drawn row this assertion was really asking how wide the lab happens to be, + // and it passed for a year only because the old fitter sliced the tail off + // mid-word instead — the foot on this very screen read `… · tab next …`. The + // law it was written for is about the arrow, so the arrow is where it looks. + if hint := a.homeHintWords(); !strings.Contains(hint, "↑ pick a match") { + t.Fatalf("the hint names the wrong arrow: %s", hint) + } + // AND THE FOOT THAT IS DRAWN IS STILL WHOLE CLAUSES OF THAT SENTENCE, never a + // word with its end sliced off. + for _, clause := range strings.Split(strings.TrimSpace(rows[len(rows)-1]), railSep) { + if !strings.Contains(a.homeHint(), clause) { + t.Fatalf("the foot drew %q, which is not a clause of the hint:\n%s", + clause, rows[len(rows)-1]) + } } } @@ -905,18 +1010,31 @@ func TestHomesRestingFootIsTheDesignsSentence(t *testing.T) { // ESC STILL WORKS, which is why losing the clause is a wording change and // not a capability going quiet. a.homeKey(key("esc")) - if !a.at(pageHome) { - t.Fatal("esc left home") + if a.at(pageHome) { + t.Fatal("esc did not close home") } - // Home has no back destination once its local layers are dismissed. - if strings.Contains(a.homeHintWords(), "esc") { - t.Fatal("Home advertised an unavailable back action") + // AND EVERY ROW THAT IS NOT THE RESTING ONE STILL ENDS WITH IT. The old law + // held for the whole screen; it holds now for the rows the design does not + // spell itself, which is every state home enters once a person acts. + a.openHome() + for _, r := range "pricing" { + a.homeKey(key(string(r))) + } + // The clause names what esc will do on THAT row — `esc clear` on a typed box, + // `esc close` on a card — so what is demanded is the key in the last slot + // rather than one spelling of it. + hint := a.placeHint() + clauses := strings.Split(hint, " · ") + if last := clauses[len(clauses)-1]; !strings.HasPrefix(last, "esc ") { + t.Fatalf("a typed home's hint reads %q, want a way out on the end", hint) } - } -// Typing clears result selection; clearing the box restores the resting list. +// THE CURSOR MOVES BETWEEN THE TWO STATES, and that is the accepted price of +// keeping the dashboard. Each state's geometry is pinned on its own: at rest the +// cursor is up in the list, and the first character takes it to the foot with the +// action row. Clearing the box brings it back. func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { lab := newHomeLab(t) now := time.Now() @@ -941,13 +1059,14 @@ func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { t.Fatalf("the resting cursor is on %q, want this window's conversation", homeName(row)) } - // The composer stays unselected until the person chooses a result. + // TYPING: the action row, on the last body row — FIVE up from the bottom of + // the frame, because the body now ends one row short of the rule: the padding + // row, then the rule, the box and the hint. a.homeKey(key("p")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("the first character kept a result selected (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("the first character did not put the cursor on the action row (kind %v)", line.kind) } - if _, ok := a.home.focusedLine(); ok { - at := homeCursorY(t, a) + if at := homeCursorY(t, a); at != height-5 { t.Fatalf("the typing cursor is on row %d of %d, want the last body row %d:\n%s", at, height, height-5, homeText(a)) } @@ -970,8 +1089,11 @@ func TestTheCursorGoesToTheFootWhileTypingAndBackAtRest(t *testing.T) { // took three keystrokes, which is the ranking being drawn at the wrong end of the // column. The scoring was never wrong; the drawing was. -// The nearest result is the best match, and further up-arrows reach weaker matches. -func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { +// ONE ↑ FROM THE ACTION ROW IS THE TOP-RANKED MATCH. That is the whole law, and +// it is asserted against the scores themselves rather than against a list of +// names, so a change to [homeRank] cannot quietly make this test agree with a +// column it no longer describes. +func TestTheBestMatchSitsNextToTheActionRow(t *testing.T) { lab := newHomeLab(t) now := time.Now() // Three hits of DIFFERENT quality on "pricing": the bare name is the strongest, @@ -1025,11 +1147,15 @@ func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { t.Fatalf("every match tied, so the order proves nothing: %+v", drawn) } - // The composer stays unselected until the person chooses a result. - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("the composer unexpectedly selected a result (kind %v)", line.kind) + // AND THE ACTION ROW IS STILL BELOW THEM ALL, so the best match is the FIRST + // conversation the walk reaches rather than the row furthest from the key. + // The row between them is `ask here` (homeexchange.go), which is the other + // thing enter can do with the sentence and not a match. + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("the cursor did not rest on the action row (kind %v)", line.kind) } a.homeKey(key("up")) + a.homeKey(key("up")) if got := homeName(a.home.focused()); got != best.name { t.Fatalf("walking up landed on %q, want the top-ranked %q (%+v)", got, best.name, drawn) } @@ -1040,12 +1166,18 @@ func TestTheBestMatchSitsNextToTheComposer(t *testing.T) { t.Fatalf("walking up reached %q, want %q (%+v)", got, drawn[i].name, drawn) } } - // The composer stays unselected until the person chooses a result. + // And ↓ comes back down toward the box, through `ask here` and onto the + // action row — one step per match, plus the one for the row between them + // (homeexchange.go). for range drawn { a.homeKey(key("down")) } - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAskHere { + t.Fatalf("↓ did not walk back to `ask here` (kind %v)", line.kind) + } + a.homeKey(key("down")) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not walk back to the action row (kind %v)", line.kind) } } @@ -1093,7 +1225,7 @@ func TestTheInvertedDropUpKeepsHeadingsAboveTheirRows(t *testing.T) { // card exists to stop. // // The first ↑ here lands on the TOP-RANKED match, which is -// [TestTheBestMatchSitsNextToTheComposer]'s law; what this one is about is that +// [TestTheBestMatchSitsNextToTheActionRow]'s law; what this one is about is that // the card changes with the cursor whichever row that turns out to be. func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { lab := newHomeLab(t) @@ -1116,15 +1248,23 @@ func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { t.Fatalf("a %d-column frame lent the detail pane nothing", width) } - // The composer stays unselected until the person chooses a result. - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("typing unexpectedly selected a result (kind %v)", line.kind) + // ON THE ACTION ROW THE PANE IS EMPTY, and that is the emptiness law rather + // than an omission: "start a new conversation" is a chat that does not exist + // yet, so there is nothing true to preview about it. + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("typing did not rest the cursor on the action row (kind %v)", line.kind) } if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { t.Fatalf("the pane previewed a conversation that does not exist yet:\n%s", strings.Join(card, "\n")) } - // The composer stays unselected until the person chooses a result. + // ↑ ONTO A MATCH DRAWS THAT MATCH'S CARD. Two of them: `ask here` is the row + // in between, and it is a thing that does not exist yet exactly as the action + // row is, so its pane is empty for the same reason (homeexchange.go). + a.homeKey(key("up")) + if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { + t.Fatalf("the pane previewed the `ask here` row:\n%s", strings.Join(card, "\n")) + } a.homeKey(key("up")) first := a.home.focused() if first.Transcript == "" { @@ -1153,11 +1293,13 @@ func TestThePreviewCardFollowsTheCursorWhileTyping(t *testing.T) { t.Fatalf("the card kept the row the cursor left:\n%s", card) } - // The composer stays unselected until the person chooses a result. + // …AND ↓ BACK ONTO THE ACTION ROW EMPTIES IT AGAIN. Three steps: two matches + // and the `ask here` row between them and the box (homeexchange.go). + a.homeKey(key("down")) a.homeKey(key("down")) a.homeKey(key("down")) - if line, ok := a.home.focusedLine(); ok { - t.Fatalf("↓ did not return to composing (kind %v)", line.kind) + if line, ok := a.home.focusedLine(); !ok || line.kind != homeAction { + t.Fatalf("↓ did not come back to the action row (kind %v)", line.kind) } if card := a.homeDetail(right, 12, a.pal); len(card) != 0 { t.Fatalf("the pane kept a card after the cursor left the match:\n%s", strings.Join(card, "\n")) @@ -1235,7 +1377,9 @@ func TestAQueryMatchesWhatATaskCameTo(t *testing.T) { if strings.Contains(text, "Tuesday") { t.Fatalf("it matched a conversation with no such outcome:\n%s", text) } - // The composer stays unselected until the person chooses a result. + // ↑ walks off the action row, past `ask here` (homeexchange.go), and up into + // the match — which is where the matches are now ([homeAction]). + a.homeKey(key("up")) a.homeKey(key("up")) if !strings.Contains(homeText(a), "Rewrote the postgres") { t.Fatalf("the pane does not show what the work came to:\n%s", homeText(a)) @@ -1268,7 +1412,7 @@ func TestNeedsYouOutranksAColdRowItTiesWith(t *testing.T) { t.Fatalf("expected two matches, got %d", len(order)) } // THE TOP-RANKED ROW IS THE LAST ONE DRAWN, because the drop-up is read - // upward out of the box ([TestTheBestMatchSitsNextToTheComposer] states the + // upward out of the box ([TestTheBestMatchSitsNextToTheActionRow] states the // law). The RANKING is what this test is about and it has not moved; only // which end of the column it is written at. if !order[len(order)-1].NeedsPerson() { @@ -1287,6 +1431,26 @@ func TestNeedsYouOutranksAColdRowItTiesWith(t *testing.T) { } // esc peels one layer at a time. +func TestEscPeelsTheQueryThenCloses(t *testing.T) { + lab := newHomeLab(t) + mine := lab.session("-tmp-alpha", "aaaa000000000001", "one", "/tmp/alpha", time.Now()) + a := lab.app(mine) + a.openHome() + for _, r := range "abc" { + a.homeKey(key(string(r))) + } + a.homeKey(key("esc")) + if !a.at(pageHome) { + t.Fatal("the first esc left home instead of clearing the query") + } + if !a.home.box.empty() { + t.Fatalf("the box still holds %q", a.home.box.String()) + } + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("the second esc did not close home") + } +} // ── the two columns ───────────────────────────────────────────────────────── @@ -1434,6 +1598,7 @@ func TestHomeMarksARowWhoseFolderIsGoneWhereverItsAddressIsDrawn(t *testing.T) { t.Fatalf("no %q on the drop-up's row:\n%s", homeGoneShort, homeText(a)) } a.homeKey(key("up")) + a.homeKey(key("up")) if got := a.home.focused().Transcript; got != gone { t.Fatalf("↑ landed on %q, want the row whose folder is gone", got) } @@ -1491,6 +1656,7 @@ func TestHomeLeavesARowWhoseFolderIsThereAlone(t *testing.T) { a.homeKey(key(string(r))) } a.homeKey(key("up")) + a.homeKey(key("up")) card := strings.Join(homeCardNow(t, a), "\n") for _, clause := range []string{"enter open", "ctrl+t new chat here", "ctrl+o open folder"} { if !strings.Contains(card, clause) { @@ -1836,9 +2002,9 @@ func TestTheWelcomeBoxRetiresWhenHomeLands(t *testing.T) { if !a.welcome.spent { t.Fatal("the welcome box was hidden rather than retired, so it can come back") } - a.closeHome() + a.homeKey(key("esc")) if a.at(pageHome) { - t.Fatal("close did not leave home") + t.Fatal("esc did not leave home") } if a.welcome.open { t.Fatalf("the welcome box appeared after home closed:\n%s", ansi.Strip(mustFrame(a))) @@ -1849,6 +2015,21 @@ func TestTheWelcomeBoxRetiresWhenHomeLands(t *testing.T) { } // esc drops into the conversation that was loaded underneath all along. +func TestEscFromTheLandingLandsInTheSession(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "the one the door picked", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "yesterday's chat", "/tmp/alpha", now.Add(-20*time.Hour)) + + a := lab.launch(mine, true) + a.homeKey(key("esc")) + if a.at(pageHome) { + t.Fatal("esc did not close the landing") + } + if a.file != mine { + t.Fatalf("esc changed the conversation to %q", a.file) + } +} // enter on the row the window is already in is the same door, and it says // nothing on the way through: the conversation is what happens next. @@ -2036,6 +2217,31 @@ func (l *homeLab) door(standing string) *app { } // TWO SPACES IN AN EMPTY BOX GO HOME. +func TestDoubleSpaceInAnEmptyBoxGoesHome(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + if !a.homeDoorOpen() { + t.Fatal("the door is shut on a machine with somewhere to go") + } + a.key(key(" ")) + if got := a.input.String(); got != " " { + t.Fatalf("the first space did not type itself: %q", got) + } + if a.at(pageHome) { + t.Fatal("one space opened home") + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces did not open home") + } + if got := a.input.String(); got != "" { + t.Fatalf("the gesture left %q behind in the box", got) + } +} // …AND IT CANNOT EAT A SPACE SOMEBODY WANTED. The first one types itself and // stays typed unless the very next key is another space. @@ -2071,21 +2277,85 @@ func TestASingleSpaceThenALetterTypesNormally(t *testing.T) { // opening a line — so the two chords the steer wave taught left a NEWLINE in a // box that had nothing in it. Nothing on the screen changed: [editor.empty] // calls a whitespace-only draft empty, so the foot went on advertising -// `esc back`, and the gesture — which asked for exactly one space and +// `space space home`, and the gesture — which asked for exactly one space and // found "\n " — never fired again. Worse, [writeDraft] kept that draft on disk // and the next window on the directory ADOPTED it, so the door stayed dead // across restarts. +func TestDoubleSpaceGoesHomeFromABoxThatShowsNothingButHoldsANewline(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + // ctrl+j is the key a terminal sends for both of the enter chords it cannot + // spell, and over an empty box it opens a line. + a.key(key("ctrl+j")) + if got := a.input.String(); got != "\n" { + t.Fatalf("ctrl+j left %q in the box, want a newline", got) + } + if !a.homeDoorShowing() { + t.Fatal("the foot stopped advertising the door, so the test is no longer about the bug") + } + a.key(key(" ")) + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("two spaces did not open home from a box holding %q", a.input.String()) + } + if got := a.input.String(); got != "" { + t.Fatalf("the gesture left %q behind in the box", got) + } +} // AND THE LAW IN ONE SENTENCE: WHEREVER THE DOOR IS ADVERTISED, TWO SPACES OPEN // IT. The advertisement and the gesture used to ask different questions about // the same box — one whitespace-insensitive, one demanding exactly one space — // and every draft the two disagreed about was a door drawn over a gesture that // could not fire. +func TestEveryBoxTheFootCallsEmptyAnswersTheDoubleSpace(t *testing.T) { + for _, held := range []string{"", " ", " ", "\n", "\n\n", "\n ", " \n", "\t"} { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "elsewhere", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + a.input.setText(held) + if !a.homeDoorShowing() { + t.Fatalf("a box holding %q is not advertising the door", held) + } + a.key(key(" ")) + a.key(key(" ")) + if !a.at(pageHome) { + t.Errorf("a box holding %q advertised the door and refused the gesture", held) + } + } +} // AND THE CARET IS WHAT "THE SPACE YOU JUST TYPED" MEANS. A space typed at the // FRONT of a box holding a newline is behind the caret exactly as one typed at // the back is, so the gesture fires either way — it is the same two keystrokes // against the same blank-looking box. +func TestTheGestureReadsTheSpaceBehindTheCaretAndNotTheEndOfTheDraft(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "here", "/tmp/alpha", now) + lab.session("-tmp-alpha", "aaaa000000000002", "elsewhere", "/tmp/alpha", now.Add(-time.Hour)) + + a := lab.door(mine) + a.input.setText("\n") + // Straight onto the caret: [editor.home] is line-relative, and the line this + // draft ends on is the empty one after the break. + a.input.cursor = 0 + a.key(key(" ")) + if got := a.input.String(); got != " \n" { + t.Fatalf("the first space landed as %q", got) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces at the front of a blank-looking box did not open home") + } +} // AND A DRAFT WITH WORDS IN IT IS STILL A DRAFT. The widened gesture may not // reach past the one thing it was always forbidden to touch: a sentence. @@ -2136,7 +2406,8 @@ func TestAPasteWhileHomeIsOpenLandsInHomesBox(t *testing.T) { lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) a := lab.door(mine) - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("home did not open") } @@ -2159,7 +2430,8 @@ func TestHomesBoxWrapsALongDraftInsteadOfTruncatingIt(t *testing.T) { lab.session("-tmp-alpha", "aaaa000000000002", "somewhere else", "/tmp/alpha", now.Add(-time.Hour)) a := lab.door(mine) - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("home did not open") } @@ -2186,7 +2458,8 @@ func TestTheDoorIsOpenWithOnlyThisConversation(t *testing.T) { if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { t.Fatalf("the hint slot reads %q on a one-conversation machine", got) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("two spaces did not open home with only this conversation") } @@ -2220,7 +2493,8 @@ func TestTheDoorIsOpenOnAMachineThatHoldsNothing(t *testing.T) { if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { t.Fatalf("the hint slot reads %q on an empty machine", got) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("two spaces did not open home on an empty machine") } @@ -2286,7 +2560,7 @@ func TestAnEmptyHomeKeepsItsShapeAtEveryWidth(t *testing.T) { for _, r := range "pricing" { drive(t, a, key(string(r))) } - if a.home.box.String() != "pricing" || a.home.picked { + if !strings.Contains(homeText(a), homeStartWord+`: "pricing"`) { t.Fatalf("at %d columns typing on an empty home does not offer a new conversation:\n%s", tc.width, homeText(a)) } } @@ -2399,7 +2673,8 @@ func TestTheDoorOpensWhenThisWindowStartsASecondConversation(t *testing.T) { if !a.homeDoorShowing() { t.Fatal("the door works and is not advertised") } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not open home") } @@ -2427,7 +2702,8 @@ func TestAReadingOfNothingDoesNotShutTheDoor(t *testing.T) { if !a.homeDoorOpen() { t.Fatal("the door is shut after home closed on a reading of nothing") } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not open home") } @@ -2486,10 +2762,10 @@ func TestTheDoorIsAdvertisedWhileIdleAndEmpty(t *testing.T) { } a.key(key("h")) - if !a.homeDoorShowing() { - t.Fatal("the back hint disappeared while typing") + if a.homeDoorShowing() { + t.Fatal("the door is still advertised while something is being typed") } - if got := a.footHint(a.width); got != microcopy+" · "+homeDoorWord { + if got := a.footHint(a.width); got != microcopy { t.Fatalf("the slot reads %q while typing", got) } } @@ -2519,11 +2795,9 @@ func TestClickingTheDoorGoesHome(t *testing.T) { if row < 0 { t.Fatal("no keys row on the frame") } - cmd, took := a.homeDoorPress(a.homeDoor.from, row) - if !took { + if _, took := a.homeDoorPress(a.homeDoor.from, row); !took { t.Fatal("a click on the door did nothing") } - drive(t, a, runCmd(cmd)...) if !a.at(pageHome) { t.Fatal("the click did not open home") } @@ -2536,7 +2810,7 @@ func TestClickingTheDoorGoesHome(t *testing.T) { } } -// The round trip is Home, Enter on a conversation, then Escape back to Home. +// THE ROUND TRIP: home → enter → the conversation → space space → home. func TestTheDoorAndHomeBounceBackAndForth(t *testing.T) { lab := newHomeLab(t) now := time.Now() @@ -2557,13 +2831,14 @@ func TestTheDoorAndHomeBounceBackAndForth(t *testing.T) { if a.file != mine { t.Fatalf("enter landed in %q", a.file) } - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not go back home") } a.homeKey(key("esc")) - if !a.at(pageHome) || a.file != mine { - t.Fatal("esc left the home destination") + if a.at(pageHome) || a.file != mine { + t.Fatal("esc did not come back to the conversation") } } @@ -2576,7 +2851,8 @@ func TestTheGestureWorksWhileATurnIsRunning(t *testing.T) { a := lab.door(mine) a.state = stateWorking - a.key(key("esc")) + a.key(key(" ")) + a.key(key(" ")) if !a.at(pageHome) { t.Fatal("the gesture did not work with a turn running") } @@ -3158,10 +3434,55 @@ func driveToPlace(t *testing.T, lab *homeLab, where page) (*app, *editor) { // space types itself into the place's own filter, exactly as it does into a // conversation's draft, and the second opens home and leaves nothing behind // in the box. +func TestDoubleSpaceFromEveryTypingPlaceGoesHome(t *testing.T) { + for _, where := range []page{pageTasks, pageMemory, pageSearch} { + lab := newHomeLab(t) + a, box := driveToPlace(t, lab, where) + if box == nil { + t.Fatalf("%v has no box to type into", where) + } + a.key(key(" ")) + if got := box.String(); got != " " { + t.Fatalf("%v: the first space did not type itself: %q", where, got) + } + if a.at(pageHome) { + t.Fatalf("%v: one space opened home", where) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("%v: two spaces did not open home", where) + } + if got := box.String(); got != "" { + t.Fatalf("%v: the gesture left %q behind in the box", where, got) + } + } +} // AND FROM A PLACE WITH NO BOX AT ALL — spend, standing — TWO BARE SPACES GO // HOME, and a letter between them disarms the door (placekeys.go's // [app.placeHomeGesture]). +func TestDoubleSpaceFromABoxlessPlaceGoesHome(t *testing.T) { + for _, where := range []page{pageSpend, pageStanding} { + lab := newHomeLab(t) + a, box := driveToPlace(t, lab, where) + if box != nil { + t.Fatalf("%v has a box, and only home starts things", where) + } + a.key(key(" ")) + if a.at(pageHome) { + t.Fatalf("%v: one space opened home", where) + } + a.key(key("x")) + a.key(key(" ")) + if a.at(pageHome) { + t.Fatalf("%v: a letter between two spaces did not disarm the door", where) + } + a.key(key(" ")) + if !a.at(pageHome) { + t.Fatalf("%v: two spaces did not open home", where) + } + } +} // SPACE IS SETTINGS' OWN VERB, and the door loses to it: `activate` is what the // panel draws space meaning on every row, and a door that swallowed the key @@ -3347,3 +3668,18 @@ func TestSpaceInTheTaskRoomPagesTheCardAndDoesNotOpenHome(t *testing.T) { t.Fatalf("space left the record at %d and pgdown at %d; they are the same key here", paged, a.taskSheet.detailTop) } } + +// The ask-here row owns this choice; a retired command must not stay in the menu +// or make an ordinary mention act as a send tag. +func TestAskHereHasNoSlashCommand(t *testing.T) { + if knownCommand("ask") || commandDoor("ask") != sendDoorNone { + t.Fatal("/ask is still registered as a command or send tag") + } + for _, value := range []string{"/ask", "explain /ask this"} { + for _, span := range commandSpans([]rune(value), true) { + if string([]rune(value)[span.from:span.to]) == "/ask" { + t.Fatalf("%q still contains an active /ask tag", value) + } + } + } +} diff --git a/internal/tui3/homearrows_test.go b/internal/tui3/homearrows_test.go index 40fb5a11c..33d8f80d6 100644 --- a/internal/tui3/homearrows_test.go +++ b/internal/tui3/homearrows_test.go @@ -170,8 +170,8 @@ func TestAHeldRosterDoesNotTakeNarrowHomesKeys(t *testing.T) { } // And esc is home's own way out rather than the roster's. drive(t, a, key("esc")) - if !a.at(pageHome) { - t.Fatal("esc left Home for the hidden roster") + if a.at(pageHome) { + t.Fatal("esc handed the keyboard back to a roster instead of closing home") } } @@ -190,9 +190,7 @@ func TestAHeldRosterDoesNotTakeTheFollowUpEnterOnANarrowHome(t *testing.T) { a.railTake(true) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil { diff --git a/internal/tui3/homeask.go b/internal/tui3/homeask.go deleted file mode 100644 index 5ddbba346..000000000 --- a/internal/tui3/homeask.go +++ /dev/null @@ -1,21 +0,0 @@ -package tui3 - -import ( - "strings" - - tea "charm.land/bubbletea/v2" -) - -// runAskCommand uses the existing home exchange door. The command stays in the -// box until there are words to ask, and a refusal leaves the draft editable. -func (a *app) runAskCommand(text string) tea.Cmd { - var shown tea.Cmd - if !a.at(pageHome) { - shown = a.showPage(pageHome) - } - text = strings.TrimSpace(text) - a.home.box.setText("/ask " + text) - a.home.picked = false - a.home.build() - return tea.Batch(shown, a.askHere(text)) -} diff --git a/internal/tui3/homeask_test.go b/internal/tui3/homeask_test.go deleted file mode 100644 index 43a6c8338..000000000 --- a/internal/tui3/homeask_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package tui3 - -import ( - "path/filepath" - "strings" - "testing" - "time" -) - -func TestAskCommandSelectsAnExchangeFromEitherComposer(t *testing.T) { - for _, home := range []bool{true, false} { - for _, line := range []string{"/ask explain this", "explain /ask this"} { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.start = func(string) (Conversation, error) { - t.Fatal("/ask started a regular conversation") - return Conversation{}, nil - } - if home { - a.openHome() - typeHome(a, line) - } else { - a.closeHome() - a.input.setText(line) - } - drive(t, a, key("enter")) - if !a.at(pageHome) || theExchange(a) == nil { - t.Fatalf("%q, home=%v: no home exchange", line, home) - } - if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "explain this" { - t.Fatalf("sent %v", lab.agent.sent) - } - if a.home.box.String() != "" { - t.Fatal("successful ask left the command in the box") - } - } - } -} - -func TestBareAskAndCompletionKeepTheQuestionEditable(t *testing.T) { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "/as") - drive(t, a, key("up"), key("enter")) - if a.home.box.String() != "/ask " || len(a.exchanges) != 0 { - t.Fatalf("completion produced %q", a.home.box.String()) - } - drive(t, a, key("enter")) - if a.home.box.String() != "/ask " || len(a.exchanges) != 0 { - t.Fatal("bare /ask must wait for the question") - } - typeHome(a, "explain this") - drive(t, a, key("enter")) - if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "explain this" { - t.Fatalf("sent %v", lab.agent.sent) - } -} - -func TestAskRefusalPreservesTheDraftAndTray(t *testing.T) { - a, dir := homeDropLab(t, "server.log") - pasteText(t, a, filepath.Join(dir, "server.log")) - typeHome(a, "/ask explain this") - a.errand = nil - drive(t, a, key("enter")) - if a.home.box.String() != "/ask explain this" || theExchange(a) != nil || len(a.chips) != 1 { - t.Fatal("unavailable ask consumed the draft") - } - if !strings.Contains(homeText(a), homeAskUnavailableWord) { - t.Fatal("ask refusal was not visible") - } -} - -func TestHomeSubmissionStaysUnselectedAcrossResizes(t *testing.T) { - lab := newHomeLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "pricing") - for _, width := range []int{180, 80, 45, 180} { - a.width = width - homeText(a) - if _, ok := a.home.focusedLine(); ok || a.home.picked { - t.Fatalf("redraw at %d selected a result", width) - } - drive(t, a, key("up")) - if a.home.focused().Transcript != mine { - t.Fatalf("first up at %d did not select the result", width) - } - drive(t, a, key("down")) - if _, ok := a.home.focusedLine(); ok || a.home.picked { - t.Fatalf("down at %d did not return to composing", width) - } - } -} - -func TestRemovingAskRestoresNewConversationAndConflictingTagsKeepTheDraft(t *testing.T) { - lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) - a := lab.app(mine) - a.openHome() - typeHome(a, "explain /ask /task this") - drive(t, a, key("enter")) - if a.home.box.String() != "explain /ask /task this" || len(lab.agent.sent) != 0 { - t.Fatal("conflicting tags consumed the draft") - } - next := &fakeAgent{model: "m"} - a.start = func(string) (Conversation, error) { - return Conversation{Agent: next, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl")}, nil - } - a.home.box.setText("explain this") - a.home.build() - drive(t, a, key("enter")) - if a.at(pageHome) || len(next.sent) != 1 || next.sent[0] != "explain this" || len(lab.agent.sent) != 0 { - t.Fatal("removing the tags did not restore a new conversation") - } -} diff --git a/internal/tui3/homeaskmd_test.go b/internal/tui3/homeaskmd_test.go index c1c79bd6d..ddf23603d 100644 --- a/internal/tui3/homeaskmd_test.go +++ b/internal/tui3/homeaskmd_test.go @@ -48,9 +48,7 @@ func askMarkdownLab(t *testing.T) *app { besideTheList(a) a.openHome() typeHome(a, "what is standing") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) return a } diff --git a/internal/tui3/homebullets_test.go b/internal/tui3/homebullets_test.go index 171d396ec..6cf06c04e 100644 --- a/internal/tui3/homebullets_test.go +++ b/internal/tui3/homebullets_test.go @@ -8,19 +8,19 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -func TestEscapeFooterNamesHomeAndMain(t *testing.T) { +func TestHomeFooterNamesDoubleSpaceOnlyWhereItWorks(t *testing.T) { a, _ := homeTabsFixture(t) a.closeHome() - if got := a.idleHint(); !strings.Contains(got, "esc home") { + if got := a.idleHint(); !strings.Contains(got, homeDoorWord) { t.Fatalf("conversation footer: %q", got) } - b := crumbApp(t) - if got := b.idleHint(); !strings.Contains(got, "esc main") || strings.Contains(got, "esc home") { - t.Fatalf("task footer: %q", got) + a.input.setText("draft") + if got := a.idleHint(); strings.Contains(got, homeDoorWord) { + t.Fatalf("draft advertises the empty-box gesture: %q", got) } - b.hintRow(b.width) - if b.homeDoor.to <= b.homeDoor.from { - t.Fatal("task Escape label has no mouse target") + b := crumbApp(t) + if got := b.idleHint(); strings.Contains(got, homeDoorWord) { + t.Fatalf("task room advertises a conversation-only gesture: %q", got) } } diff --git a/internal/tui3/homedrop_test.go b/internal/tui3/homedrop_test.go index 68c696add..7280abd88 100644 --- a/internal/tui3/homedrop_test.go +++ b/internal/tui3/homedrop_test.go @@ -88,7 +88,7 @@ func TestDroppingAFileOnHomeLightsStartWithNothingTyped(t *testing.T) { t.Fatalf("chips are %v, want %v", chipNames(a), want) } line, ok := a.home.focusedLine() - if ok { + if !ok || line.kind != homeAction { t.Fatalf("the cursor rests on %v, want the action row", line.kind) } if !strings.Contains(homeText(a), "server.log") { diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index bf0746f70..b3e80abc2 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -151,7 +151,8 @@ const ( // homeExchangeRow is the row kind an exchange wears in the left column. // -// It is numbered outside the homeRowKind block to keep its identity separate. The +// IT IS DECLARED HERE FOR [homeAskHere]'S REASON and given the next value above +// it, so neither can collide with the iota block another lane is editing. The // name carries `Row` because [homeExchange] is the thing itself and this is its // line on the screen — two names for two objects that must not be confused. const homeExchangeRow homeRowKind = 201 @@ -166,6 +167,15 @@ const homeExchangeRow homeRowKind = 201 // starts to be a second transcript in a pane forty cells wide. const exchangeStripRows = 2 +// homeAskHere is the row kind of that second action row. +// +// IT IS DECLARED HERE AND NOT IN [homeRowKind]'s OWN BLOCK, on purpose: the +// iota block in home.go is being edited by another lane in the same wave, and a +// constant appended to it would be a conflict over a line that says nothing. +// The value is far above the block's last member so the two can never collide, +// and [homeLine.stop] and [app.homeEnter] name it the way they name the rest. +const homeAskHere homeRowKind = 200 + // exchangeKind is what one drawn line of the exchange is. type exchangeKind uint8 @@ -1277,7 +1287,14 @@ func (a *app) exchangeKey(ex *homeExchange, msg tea.KeyPressMsg) tea.Cmd { return nil case "esc": - // Leave the reply draft in place when returning to the list. + // ONE LAYER AT A TIME, home's own rule: a half-typed follow-up is + // cleared first and the second esc hands the keyboard back. NEITHER + // CLOSES THE EXCHANGE — it stands as a row on the column, which on a + // narrow frame is also how the list comes back over the stacked pane. + if !ex.box.empty() { + ex.box.reset() + return nil + } ex.focused, ex.onOffer, ex.changing = false, false, false return nil @@ -2148,7 +2165,7 @@ func exchangeAnswerWords(q session.Question) string { // does, and `continue as a conversation` when the first reply has landed. func exchangeHint(ex *homeExchange) string { if ex.changing { - return homeAskChangeWord + " · esc back" + return homeAskChangeWord + " · esc clear" } var parts []string if ex.asking() { diff --git a/internal/tui3/homeexchange_test.go b/internal/tui3/homeexchange_test.go index 70ecc32f6..850fdfd7c 100644 --- a/internal/tui3/homeexchange_test.go +++ b/internal/tui3/homeexchange_test.go @@ -189,33 +189,67 @@ func typeHome(a *app, text string) { // which is the cheapest keystroke on the screen after enter itself — and // `start a new conversation` keeps the rest, so nothing a person already knew // how to do changed meaning. -func TestHomeSubmissionRowsAndHintsAreAbsent(t *testing.T) { +func TestTypingAtHomeOffersAskHereDirectlyAboveStartingAConversation(t *testing.T) { lab := newErrandLab(t) - mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + now := time.Now() + mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", now) + a := lab.app(mine) a.openHome() typeHome(a, "remind me at 6") - if _, ok := a.home.focusedLine(); ok { - t.Fatal("composer selected a result") + + ask, start := -1, -1 + for at, line := range a.home.lines { + switch line.kind { + case homeAskHere: + ask = at + case homeAction: + start = at + } + } + if ask < 0 || start < 0 { + t.Fatalf("both rows should be on the list, got ask=%d start=%d", ask, start) + } + if start != ask+1 { + t.Fatalf("`ask here` should sit directly above the action row, got ask=%d start=%d", ask, start) + } + if a.home.cursor != start { + t.Fatalf("the cursor should still rest on the action row, it is on line %d", a.home.cursor) } - if frame := homeText(a); strings.Contains(frame, "? ask here:") || strings.Contains(frame, homeStartWord) { - t.Fatalf("action row remains: %s", frame) + frame := homeText(a) + if !strings.Contains(frame, homeAskHereWord+`: "remind me at 6"`) { + t.Fatalf("the ask row does not quote the sentence:\n%s", frame) + } + if !strings.Contains(frame, homeStartWord+`: "remind me at 6"`) { + t.Fatalf("the action row is gone:\n%s", frame) + } + // The hint under the box names both readings of the same characters, and it + // names the ask by the ARROW that reaches it rather than by a chord most + // terminals cannot send (home.go's [app.homeHintWords] holds the argument). + if hint := errandRows(a)[len(errandRows(a))-1]; !strings.Contains(hint, "↑ ask here") || + !strings.Contains(hint, "enter starts a new conversation") { + t.Fatalf("the hint does not say both things enter can do:\n%s", hint) } - if hint := a.homeHintWords(); strings.Contains(hint, "ask here") || strings.Contains(hint, "starts a new conversation") { - t.Fatalf("mode hints remain: %s", hint) + if hint := errandRows(a)[len(errandRows(a))-1]; strings.Contains(hint, "ctrl+enter") { + t.Fatalf("the foot still advertises a chord most terminals cannot send:\n%s", hint) } } -// With no matches, up cannot select a submission mode or a nonexistent result. -func TestUpWithNoMatchesKeepsTheComposer(t *testing.T) { +// TestOneUpFromTheRestRowLandsOnAskHere pins the keystroke the row was placed +// for. A row that has to be walked to past a heading and a blank is a row +// nobody reaches. +func TestOneUpFromTheRestRowLandsOnAskHere(t *testing.T) { lab := newErrandLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now()) + a := lab.app(mine) a.openHome() typeHome(a, "remind me at 6") a.homeKey(key("up")) - if _, ok := a.home.focusedLine(); ok { - t.Fatal("up selected a nonexistent result") + + line, ok := a.home.focusedLine() + if !ok || line.kind != homeAskHere { + t.Fatalf("↑ from the rest row should land on `ask here`, it landed on kind %d", line.kind) } } @@ -232,9 +266,7 @@ func TestAskHereMakesItsFolderOutsideTheProjectsAndSendsTheSentence(t *testing.T }) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) if len(lab.agent.sent) != 1 || lab.agent.sent[0] != "remind me at 6" { t.Fatalf("the sentence should have gone to the errand agent, it sent %v", lab.agent.sent) @@ -345,9 +377,7 @@ func TestTheCardInThePaneIsAnsweredWithOne(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) frame := homeText(a) for _, want := range []string{"remind me at 6 to leave", "at 6 today", "about $0.02, once", "1 yes, set it up"} { @@ -437,9 +467,7 @@ func TestTheErrandHintNamesOnlyTheAnswersTheCardDrew(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, c.item.Words) - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil || ex.view == nil { @@ -503,9 +531,7 @@ func TestTheCardInThePaneIsDeclinedWithZero(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) // THE HINT NAMES IT, because this is the only way out of the question that // answers it. @@ -554,9 +580,7 @@ func TestContinueAsAConversationMovesTheFolderIntoTheBucket(t *testing.T) { }) a.openHome() typeHome(a, "what did we decide about pricing") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) made := lab.dirs[0] id := filepath.Base(made) @@ -625,9 +649,7 @@ func TestStandingUpMovesTheExchangeUnderTheItemItMade(t *testing.T) { }) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) made := lab.dirs[0] store, err := standing.Open(lab.standing) @@ -704,9 +726,7 @@ func TestSomethingStandingKeepsItsCardAndHandsBackTheKeyboard(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil || ex.view == nil { @@ -750,9 +770,7 @@ func TestEscLeavesTheExchangeAliveAndTheListMoving(t *testing.T) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key("esc")) if theExchange(a) == nil { @@ -808,9 +826,7 @@ func exchangeLab(t *testing.T) (*errandLab, *app) { besideTheList(a) a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) return lab, a } @@ -937,9 +953,7 @@ func TestSayingYesHandsTheKeyboardBackToTheList(t *testing.T) { // THE TWO DRIVES ARE THE POINT. [drive] queues what a command produced // behind the keys already in hand, so a `1` sent in the same call would be // pressed before the card it answers had arrived. - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key("1")) ex := theExchange(a) @@ -1063,9 +1077,7 @@ func TestAChangedCardIsReplacedByTheOneThatFollowsIt(t *testing.T) { a := lab.app(mine, []session.Event{standingProposal(7, "remind me at 6 to leave"), {Kind: session.EventTurnDone}}) a.openHome() typeHome(a, "remind me at 6 to leave") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) drive(t, a, key(questionCommentKey)) ex := theExchange(a) @@ -1121,7 +1133,8 @@ func askedHere(t *testing.T, lab *errandLab, a *app, said string) *homeExchange // into HOME's box, and after the first one the pane has the hand — which is // exactly what a person does with tab or esc before typing again. a.homeTakeList() - typeHome(a, "/ask "+said) + typeHome(a, said) + a.homeKey(key("up")) a.homeKey(key("enter")) ex := theExchange(a) if ex == nil { @@ -1468,9 +1481,7 @@ func TestAFollowUpGetsTheSameSignalAsTheFirstTurn(t *testing.T) { a.clock = func() time.Time { return at } a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if !ex.over() { diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index dbdd778b2..8b1efa1e8 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -1535,7 +1535,7 @@ func (a *app) refreshGridReadings(now time.Time) tea.Cmd { // homePreselect puts the cursor on THE CONVERSATION THIS WINDOW WAS IN BEFORE // THIS ONE (law 6): the most recent key on this window's own stack that is not // the one in front and is on the grid. Enter is then a switch in two keys, and -// repeated Escape presses stay on Home. +// Escape returns to the conversation behind Home. func (a *app) homePreselect() { if !a.home.gridOn() { return diff --git a/internal/tui3/homepanel_next.go b/internal/tui3/homepanel_next.go index 809a0ad87..4a78220c1 100644 --- a/internal/tui3/homepanel_next.go +++ b/internal/tui3/homepanel_next.go @@ -14,10 +14,9 @@ import ( // whole machine. Every row opens the standing place, where the orders are kept. type nextPanel struct{ homePanelBase } -// homeScheduledWord is the panel's heading: one word, no explainer (owner, -// 2026-09-15; it was `next up · reminders & routines`, which named two of the -// four kinds of order that stand on it). -const homeScheduledWord = "scheduled" +// homeScheduledWord matches the standing place the heading opens. The panel +// includes reminders, routines, watches and rules, not only scheduled work. +const homeScheduledWord = "standing" func (nextPanel) rows(in *homeGridInput) homePanelRows { views := nextActive(in) @@ -129,7 +128,7 @@ func nextUpSaid(view StandingItemView, now time.Time) string { return rowClauses(nextUpKindWord(item), words, next, last) } -// The fixed words of a `scheduled` row's sentence, spelled once. +// The fixed words of a `standing` row's sentence, spelled once. const ( nextUpGoesOffWord = "goes off " nextUpNextWord = "next " diff --git a/internal/tui3/homephone.go b/internal/tui3/homephone.go index bcfe31712..ac3cb2475 100644 --- a/internal/tui3/homephone.go +++ b/internal/tui3/homephone.go @@ -81,7 +81,7 @@ import ( ) // The row kinds the inbox adds, declared HERE and given values far above the -// iota block in home.go to keep its identity separate: that block is being edited +// iota block in home.go for [homeAskHere]'s reason: that block is being edited // by other lanes in the same wave, and a constant appended to it would be a // conflict over a line that says nothing. const ( @@ -553,8 +553,10 @@ func (a *app) homePhoneFrame(width, height int) ([]string, []int, int, int) { // homePhoneHead is the one row at the top: what this is, and the way out. func (a *app) homePhoneHead(width int, pal palette) string { head := " " + pal.bold(pal.ink("home")) - if gap := width - ansi.StringWidth(head) - 1; gap > 0 { - head += strings.Repeat(" ", gap) + escape := pal.dim("esc close") + if ansi.StringWidth(head)+ansi.StringWidth(escape)+2 <= width { + gap := width - ansi.StringWidth(head) - ansi.StringWidth(escape) - 1 + head += strings.Repeat(" ", gap) + escape } return head } @@ -712,6 +714,25 @@ func (a *app) homePhoneWords(line homeLine, pal palette) (string, string, noteIn case homeProject: return homeFoldMark(line.folded, pal) + " " + line.project, h.projectNote(line.proj, h.world.Read, pal.ascii), h.projectInk(line.proj) + case homeAskHere: + label := homeAskHereWord + if text := strings.TrimSpace(h.box.String()); text != "" { + label += ": " + text + } + return homeAskHereGlyph + " " + label, "", nil + case homeAction: + label := homeStartWord + if text := strings.TrimSpace(h.box.String()); text != "" { + // A COMMAND IS SAID THE SAME WAY IN BOTH COLUMNS. Enter dispatches a + // "/" line here exactly as it does on a wide frame ([app.homeEnter] is + // the one router), so the clause comes from the one place it is + // spelled ([homeView.runLabel]) rather than being written again narrower. + if word := h.runLabel(text); word != "" { + return homeStartGlyph + " " + word, "", nil + } + label += ": " + text + } + return homeStartGlyph + " " + label, "", nil } return "", "", nil } @@ -831,7 +852,8 @@ func phoneBar(width int, words []string, pal palette) (string, []hudSpan) { return out.String(), spans } -// homeInboxBar opens the selected result. Submission modes belong to the box. +// homeInboxBar is the bar over the list: open what the cursor is on, start +// something new, ask the box here. func (a *app) homeInboxBar() []homeBarTarget { var targets []homeBarTarget if a.home.box.empty() { @@ -839,6 +861,13 @@ func (a *app) homeInboxBar() []homeBarTarget { return a.homeKey(tea.KeyPressMsg{Code: tea.KeyRight}) }}) } + if !a.home.box.empty() { + return []homeBarTarget{ + {word: "open", do: func(a *app) tea.Cmd { return a.homeEnter() }}, + {word: "new", do: func(a *app) tea.Cmd { return a.homeStart(strings.TrimSpace(a.home.box.String())) }}, + {word: homeAskHereWord, do: func(a *app) tea.Cmd { return a.askHere(strings.TrimSpace(a.home.box.String())) }}, + } + } return append(targets, []homeBarTarget{ {word: "open", do: func(a *app) tea.Cmd { return a.homeEnter() }}, }...) @@ -908,7 +937,9 @@ func (a *app) homePhonePress(x, y int) tea.Cmd { return nil } a.home.cursor = at - a.home.picked = true + if line.kind != homeAction { + a.home.picked = true + } a.touch() return a.homeEnter() } diff --git a/internal/tui3/homephone_test.go b/internal/tui3/homephone_test.go index bb3a1afed..9212490cf 100644 --- a/internal/tui3/homephone_test.go +++ b/internal/tui3/homephone_test.go @@ -180,8 +180,8 @@ func TestTypingOnAPhoneSearchesWithNoSections(t *testing.T) { if strings.Contains(text, homePhoneWaitingWord+"\n") && strings.Contains(text, homePhoneNewsWord) { t.Fatalf("a search kept the sections:\n%s", text) } - if strings.Contains(text, homeStartWord) || strings.Contains(text, "? ask here:") { - t.Fatalf("removed action rows remain at the foot:\n%s", text) + if !strings.Contains(text, homeStartWord) || !strings.Contains(text, homeAskHereWord) { + t.Fatalf("the action rows are not at the foot:\n%s", text) } } @@ -401,7 +401,7 @@ func TestTheTaskRecordsFootIsBandsOnAPhone(t *testing.T) { // ── the action bar ────────────────────────────────────────────────────────── -func TestThePhoneInboxBarDoesNotOfferSubmissionModes(t *testing.T) { +func TestThePhoneActionBarIsThreeTargets(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "port the picker", "/tmp/alpha", time.Now()) a := phoneHome(t, lab, mine) @@ -603,9 +603,7 @@ func TestAnErrandOnAPhoneIsTheSameSheet(t *testing.T) { a.width, a.height = 50, 30 a.openHome() typeHome(a, "remind me at 6") - a.home.box.setText("/ask " + a.home.box.String()) - a.home.build() - drive(t, a, key("enter")) + drive(t, a, key("up"), key("enter")) ex := theExchange(a) if ex == nil { diff --git a/internal/tui3/homeprojectpaste_test.go b/internal/tui3/homeprojectpaste_test.go index 1cd31039a..56837bc30 100644 --- a/internal/tui3/homeprojectpaste_test.go +++ b/internal/tui3/homeprojectpaste_test.go @@ -2,6 +2,7 @@ package tui3 import ( "path/filepath" + "strings" "testing" ) @@ -16,7 +17,7 @@ func TestHomeFolderPasteOffersOneEnterToStartThere(t *testing.T) { pasteText(t, a, dir) for i := 0; i < 3; i++ { a.home.build() - if a.home.pastedProject() != dir { + if a.home.startLabel() != homeStartWord+" in "+dir { t.Fatal("the offer did not survive an idle rebuild") } } @@ -32,13 +33,13 @@ func TestAnyOtherKeyDismissesTheHomeFolderPasteOffer(t *testing.T) { a, dir := homeDropLab(t) pasteText(t, a, dir) drive(t, a, key(press)) - if a.home.pastedProject() != "" { + if a.home.pastedProject() != "" || strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("a non-Enter key kept the folder offer active") } // Returning to the same text cannot infer a new offer from it. a.home.box.setText(dir) a.home.build() - if a.home.pastedProject() != "" { + if strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("the old path reactivated the offer") } }) @@ -70,7 +71,7 @@ func TestDismissedFolderPasteSendsTheEditedTextAtTheChosenProject(t *testing.T) drive(t, a, key(" ")) pasteText(t, a, "explain this project") want := dir + " explain this project" - if got := a.home.pastedProject(); got != "" { + if got := a.home.startLabel(); !strings.HasPrefix(got, homeStartWord+": ") { t.Fatalf("edited folder text was not an ordinary message: %q", got) } drive(t, a, key("enter")) @@ -97,7 +98,7 @@ func TestHomeFolderPasteRearmsOnlyAfterClearingAndPasting(t *testing.T) { func TestTypingAFolderPathDoesNotOfferAProject(t *testing.T) { a, dir := homeDropLab(t) typeHome(a, dir) - if a.home.pastedProject() != "" { + if a.home.pastedProject() != "" || strings.HasPrefix(a.home.startLabel(), homeStartWord+" in ") { t.Fatal("typing a path activated the paste-only offer") } } diff --git a/internal/tui3/homequestionrows_test.go b/internal/tui3/homequestionrows_test.go index cb1a97b1b..5083c8f59 100644 --- a/internal/tui3/homequestionrows_test.go +++ b/internal/tui3/homequestionrows_test.go @@ -10,7 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -func TestTasksSpendAndSettingsAdvertiseEscapeHome(t *testing.T) { +func TestTasksSpendAndSettingsAdvertiseEscapeClose(t *testing.T) { for _, place := range everyPlaceTable() { if place.id != pageTasks && place.id != pageSpend && place.id != pageSettings { continue @@ -20,13 +20,13 @@ func TestTasksSpendAndSettingsAdvertiseEscapeHome(t *testing.T) { for _, width := range []int{180, 80, 44} { a.width = width rows := strings.Split(placeFrameText(a), "\n") - if last := rows[len(rows)-1]; !strings.Contains(last, "esc home") { + if last := rows[len(rows)-1]; !strings.Contains(last, "esc close") { t.Fatalf("at width %d, footer = %q", width, last) } } drive(t, a, key("esc")) - if !a.at(pageHome) { - t.Fatal("Escape did not return to Home") + if !a.at(pageNone) { + t.Fatal("Escape did not return to the conversation") } }) } diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 55e98ef71..b118d0348 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -182,8 +182,6 @@ const ( func homeFate(word, rest string) string { rest = strings.TrimSpace(rest) switch canonicalCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { - case "ask": - return fateAnswers case "model": return fateTargetModel case "folder": @@ -280,9 +278,6 @@ func (a *app) homeSlash(line string) tea.Cmd { name, rest, _ := strings.Cut(strings.TrimPrefix(line, "/"), " ") rest = strings.TrimSpace(rest) word := canonicalCommand(strings.ToLower(name)) - if word == "ask" { - return a.runAskCommand(rest) - } h.box.reset() h.build() switch homeFate(word, rest) { diff --git a/internal/tui3/homeslash_test.go b/internal/tui3/homeslash_test.go index f7c882e72..7327c189f 100644 --- a/internal/tui3/homeslash_test.go +++ b/internal/tui3/homeslash_test.go @@ -19,10 +19,7 @@ import ( // homeKindAt is the kind of the line the cursor rests on, for asserting where // the arrows landed without trusting the order the drop-up was built in. func homeKindAt(a *app) homeRowKind { - if line, ok := a.home.focusedLine(); ok { - return line.kind - } - return homeRowKind(255) + return a.home.lines[a.home.cursor].kind } // TestHomeSlashOffersCommandRows: typing a slash word offers the matching @@ -49,21 +46,21 @@ func TestHomeSlashOffersCommandRows(t *testing.T) { // "/clea" reaches /new through its clear alias, and the row that appears // must be the canonical one — the word this surface runs — with the alias // printed beside it, the same bargain chat's list makes. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/clea") if text := homeText(a); !strings.Contains(text, "/new") { t.Fatalf("typing clea did not offer the canonical /new row:\n%s", text) } // "/mo" offers /model — the acceptance's own word. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/mo") if text := homeText(a); !strings.Contains(text, "/model") { t.Fatalf("typing /mo did not offer the model command row:\n%s", text) } // "/conf" reaches /settings through its config alias. - a.homeKey(key("ctrl+u")) + a.homeKey(key("esc")) typeHome(a, "/conf") if text := homeText(a); !strings.Contains(text, "/settings") { t.Fatalf("typing /conf did not offer the canonical settings row:\n%s", text) @@ -438,8 +435,8 @@ func TestModelThenEscLeavesNoPickerOverTheConversation(t *testing.T) { } runCmd(a.key(key("esc"))) runCmd(a.key(key("esc"))) - if !a.at(pageHome) { - t.Fatal("two escapes left home") + if a.at(pageHome) { + t.Fatal("two escapes did not leave home") } if a.pick.open || a.target.pick.open { t.Fatal("a model list is standing over the conversation home was in front of") @@ -577,6 +574,7 @@ func TestHomeSlashSelectionSurvivesTheSlowTick(t *testing.T) { typeHome(a, "/set") a.homeKey(key("up")) + a.homeKey(key("up")) if k := homeKindAt(a); k != homeCommand { t.Fatalf("two ↑ landed on %v, want a command row", k) } @@ -629,14 +627,14 @@ func TestHomeOfferedPlaceSelectionSurvivesTheSlowTick(t *testing.T) { } } -// TestHomeSlashDoesNotAddASubmissionRow: the resting row and the foot under it +// TestHomeSlashActionRowSaysItWillRun: the resting row and the foot under it // both name what enter will actually do with a slash line. // // THE ROW MAY NOT PROMISE A CONVERSATION IT WILL NOT START. Enter on the action // row dispatches a "/" line ([app.homeEnter]), and the row went on reading // `+ start a new conversation: "/settings"` while it did — which is the one row // on this screen whose whole job is to say what the key means. -func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { +func TestHomeSlashActionRowSaysItWillRun(t *testing.T) { lab := newHomeLab(t) mine := lab.session("-tmp-alpha", "aaaa000000000001", "porting the resume picker", "/tmp/alpha", time.Now()) a := lab.app(mine) @@ -648,13 +646,13 @@ func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { t.Fatalf("the cursor left the action row onto %v", k) } text := homeText(a) - if strings.Contains(text, homeStartGlyph+" run /settings") { - t.Fatalf("a removed run-command action row was rendered:\n%s", text) + if !strings.Contains(text, "/settings") { + t.Fatalf("the action row does not say it will run the command:\n%s", text) } if strings.Contains(text, homeStartWord+`: "/settings"`) { t.Fatalf("the action row still offers to start a conversation with the command:\n%s", text) } - if hint := a.homeHintWords(); strings.Contains(hint, "enter runs this command") { + if hint := a.homeHintWords(); !strings.Contains(hint, "enter") { t.Fatalf("the foot reads %q, want it naming the run", hint) } @@ -662,10 +660,10 @@ func TestHomeSlashDoesNotAddASubmissionRow(t *testing.T) { // always said, quoted words and all. a.homeKey(key("esc")) typeHome(a, "pricing") - if text := homeText(a); strings.Contains(text, homeStartWord+`: "pricing"`) { - t.Fatalf("a sentence rendered a removed action row:\n%s", text) + if text := homeText(a); !strings.Contains(text, homeStartWord+`: "pricing"`) { + t.Fatalf("a sentence lost the row it has always had:\n%s", text) } - if hint := a.homeHintWords(); strings.Contains(hint, "enter starts a new conversation and sends this") { + if hint := a.homeHintWords(); !strings.Contains(hint, "enter starts a new conversation and sends this") { t.Fatalf("a sentence's foot reads %q", hint) } } @@ -688,7 +686,7 @@ func TestHomeSlashChosenRowWritesTheNameAndNotThePlaceholder(t *testing.T) { // the argless form, which runs and opens the picker instead, so the walk // looks for the row by what it is rather than counting keystrokes. for i := 0; i < len(a.home.lines); i++ { - if line, ok := a.home.focusedLine(); ok && line.kind == homeCommand && line.cmd.args != "" { + if line := a.home.lines[a.home.cursor]; line.kind == homeCommand && line.cmd.args != "" { break } a.homeKey(key("down")) @@ -751,7 +749,7 @@ func TestHomeSlashDoesNotSwallowAPastedPath(t *testing.T) { // The label itself rather than the painted row: a temp directory's path is // longer than the column, and this test is about which of the two readings // the row took, not about where it was cut. - if got := a.home.pastedProject(); got != dir { + if got := a.home.startLabel(); got != homeStartWord+" in "+dir { t.Fatalf("the action row says %q, want it offering the folder", got) } if hint := a.homeHintWords(); strings.Contains(hint, "enter runs this command") { diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 60f7decc1..3caf40468 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -612,7 +612,7 @@ func (a *app) hopStripOrder(rows []hopRow) []hopRow { // The feature was invisible until you had learned the thing it exists for. // // THE WORLD IS READ ON THE KEYSTROKE, ONCE, and that is affordable for one -// reason: this gesture REPLACES pressing `esc`, which takes the same +// reason: this gesture REPLACES pressing `space space`, which takes the same // reading and then draws a whole page with it. It can be no slower than what a // person does today to answer the same question. func (a *app) hopRest(open []hopRow, now time.Time) []hopRow { diff --git a/internal/tui3/input.go b/internal/tui3/input.go index abda1dfd6..ce7d70a19 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -584,14 +584,17 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // frees every printable key for the filter box and, on the card, for the // value somebody is typing into a field. // - // An agent-raised card can be deferred with Escape and resumed with - // /subharness; only an explicit answer resolves the offer. + // WITH ONE CARD THAT IS NOT OPENED BY A COMMAND: the intake chat itself + // raised. It comes through this same door because it is the same overlay, + // and the only thing that differs is what esc means on it — a NO, answered + // back to the turn that is waiting on it, rather than a way out of a page + // somebody opened to read ([app.answerSubharnessOffer]). if a.subPage.open && msg.String() != "ctrl+c" { return a.subPageKey(msg) } if msg.String() == "ctrl+c" { - // INTERRUPT FIRST. While a turn runs ctrl+c stops it — + // INTERRUPT FIRST. While a turn runs ctrl+c is the same key esc is — // a person hitting it mid-turn is reaching for the model, not for the // door, and every terminal habit in the world says that keystroke stops // the RUNNING thing. @@ -819,7 +822,17 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { a.recallCancel() return nil } - return a.openHome() + // THE DOUBLE ESC IS THE REWIND'S DOOR, and it is read here rather than + // above the interrupt because the interrupt is not for sale (rewind.go): + // the first esc means exactly what it always meant and ARMS the mode on its + // way past, and only a second one inside the window is taken. A stray esc + // after the window has lapsed changes nothing. + cmd, taken := a.escRewind() + if taken { + return cmd + } + a.interrupt() + return cmd case "enter": if a.steerAvailable() { @@ -1292,6 +1305,16 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return a.syncLists() } + // TWO SPACES IN AN EMPTY BOX ARE THE DOOR HOME (home.go). It is read here, + // at the very bottom of the router, because it must lose to every other + // meaning a space could have on this surface — inside a paste bracket, in a + // filter box, in copy mode, in any overlay — and because the first of the + // two spaces has already typed itself perfectly ordinarily one keystroke + // ago, through the line below. + if a.homeGesture(msg) { + a.input.reset() + return tea.Batch(a.edited(), a.openHome()) + } if text := msg.Key().Text; text != "" { // The ordinary case: a key that carries text types it. // @@ -1479,8 +1502,6 @@ func (a *app) enterLine(marked bool) tea.Cmd { return a.openStanding() } return a.standingSayShown(tagWords, tagShown) - case sendDoorAsk: - return a.runAskCommand(tagWords) case sendDoorTask: return a.runTaskCommand(tagWords) } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index fbe83d349..27f904561 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -212,7 +212,7 @@ var notices = []notice{ { id: "rewind-after-long-answer", slot: slotHint, priority: 70, armed: func(a *app) bool { return a.lastAnswerRunes() >= longAnswerRunes }, - text: "/rewind takes back an earlier message", + text: "esc esc takes back the last message", retire: eventRewound, }, { diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 623392f98..0314df44b 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -416,7 +416,7 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { // LOWEST RUNG. A running turn's own key outranks it, and so does a box with // words in it. a.state = stateWorking - if got := a.footHint(a.width); got != "ctrl+c interrupt" { + if got := a.footHint(a.width); got != "esc interrupt" { t.Fatalf("a tip outranked a running turn's key: %q", got) } a.state = stateIdle diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6f3d5d8a3..48d882ad8 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -2273,12 +2273,7 @@ func (a *app) closeModals() { a.connPanel.close() a.harnPanel.close() a.permPanel.close() - // Navigation hides an unanswered offer without resolving or losing it. - if a.subPage.card.asked() { - a.subPage.open = false - } else { - a.subPage.close() - } + a.subPage.close() // AND HOME'S OWN MODEL LIST, which IS drawn where it stands and is still a // list nobody left open on purpose: walking to another place and back to a // list you had not finished with is a list you have to remember opening diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index 433945d20..3b96075be 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -527,14 +527,17 @@ func TestAPlaceOutranksEveryConversationTheWordsAlsoMatch(t *testing.T) { for _, r := range "sta" { drive(t, a, key(string(r))) } - place, lastChat := -1, -1 + place, lastChat, ask, action := -1, -1, -1, -1 for i, line := range a.home.lines { switch line.kind { case homePlace: place = i case homeSession: lastChat = i - + case homeAskHere: + ask = i + case homeAction: + action = i } } if place < 0 || lastChat < 0 { @@ -544,6 +547,11 @@ func TestAPlaceOutranksEveryConversationTheWordsAlsoMatch(t *testing.T) { t.Fatalf("the place is drawn above a conversation it outranks: place at %d, last chat at %d\n%s", place, lastChat, placeFrameText(a)) } + // AND THE TWO ROWS THAT ACT ON THE SENTENCE STAY UNDER IT. They are one + // cluster against the box and are not results at all ([homeAction]). + if ask < place || action < ask { + t.Fatalf("the sentence cluster moved: place %d, ask here %d, start %d", place, ask, action) + } // AND THE ROW SAYS WHAT IS BEHIND IT, not just what kind of thing it is. if !strings.Contains(placeFrameText(a), placeRowWord) { t.Fatalf("the offered place does not say what kind of thing it is:\n%s", placeFrameText(a)) @@ -770,9 +778,9 @@ func placeAt(id page) int { // seizing keys somebody has muscle memory for. The digits are the spare class. func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) { a := placeApp(t) - a.closeHome() + drive(t, a, key("esc")) if a.at(pageHome) { - t.Fatal("close did not put the conversation back") + t.Fatal("esc did not put the conversation back") } drive(t, a, key("alt+2")) if a.page != pageTasks || !a.at(pageTasks) { @@ -784,7 +792,7 @@ func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) { t.Fatalf("%s from the conversation landed on %q", placeChord(pageStanding), a.page.word()) } // AND `tab` IS STILL THE CONVERSATION'S OWN KEY THERE. - a.leavePlace() + drive(t, a, key("esc")) page := a.page drive(t, a, key("tab")) if a.page != page || a.at(pageTasks) || a.at(pageStanding) { diff --git a/internal/tui3/park.go b/internal/tui3/park.go index 14bd028d9..d76a25f37 100644 --- a/internal/tui3/park.go +++ b/internal/tui3/park.go @@ -251,7 +251,7 @@ func (a *app) recallParkedAt(i int) bool { // same question the stop is, because a turn that is winding down has no // boundary left to steer into either. var parkedHint = []string{ - "waits for this answer", "ctrl+c stops and drops", steerArrowWord, "↑ or click to edit", + "waits for this answer", "esc stops and drops", steerArrowWord, "↑ or click to edit", } // parkedHeight is how many rows the block takes: the messages, then the one dim diff --git a/internal/tui3/park_test.go b/internal/tui3/park_test.go index eae7f3506..eebfbb10d 100644 --- a/internal/tui3/park_test.go +++ b/internal/tui3/park_test.go @@ -203,9 +203,9 @@ func TestTheParkedLineTrimsFromTheRightOnANarrowFrame(t *testing.T) { } } -// CTRL+C clears the parked queue at the keypress, before the interrupted stream +// ESC clears the parked queue at the keypress, before the interrupted stream // closes, so there is no inert waiting block left during teardown. -func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { +func TestEscClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "do much more of a deep research please") drive(t, a, frameMsg{}) @@ -220,13 +220,13 @@ func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { t.Fatalf("the hint slot never offered the stop: %q", got) } - drive(t, a, key("ctrl+c"), frameMsg{}) + drive(t, a, key("esc"), frameMsg{}) if !a.windingDown() { - t.Fatal("the surface is not winding down after ctrl+c") + t.Fatal("the surface is not winding down after esc") } if len(a.parks) != 0 { - t.Fatalf("ctrl+c left a message parked during teardown: %+v", a.parks) + t.Fatalf("esc left a message parked during teardown: %+v", a.parks) } body := plain(frame(a)) if strings.Contains(body, "do much more of a deep research please") || strings.Contains(body, parkedHint[0]) { @@ -236,7 +236,7 @@ func TestCtrlCClearsTheParkedBlockWhileTheTurnIsWindingDown(t *testing.T) { agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}, frameMsg{}) if len(agent.sent) != 1 { - t.Fatalf("the stream close sent the message ctrl+c dropped: %q", agent.sent) + t.Fatalf("the stream close sent the message esc dropped: %q", agent.sent) } } @@ -293,39 +293,39 @@ func TestParkedMessagesGoOneAtATimeInTheOrderTheyWereTyped(t *testing.T) { } } -// ── ctrl+c ───────────────────────────────────────────────────────────────────── +// ── esc ───────────────────────────────────────────────────────────────────── -// CTRL+C WITH A MESSAGE WAITING STOPS THE ANSWER AND DROPS IT. -func TestCtrlCWithAMessageWaitingStopsTheAnswerAndDropsIt(t *testing.T) { +// ESC WITH A MESSAGE WAITING STOPS THE ANSWER AND DROPS IT. +func TestEscWithAMessageWaitingStopsTheAnswerAndDropsIt(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "no, the other file") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}) if agent.stops != 1 { - t.Fatalf("ctrl+c did not stop the answer: %d stops", agent.stops) + t.Fatalf("esc did not stop the answer: %d stops", agent.stops) } if len(agent.sent) != 1 { - t.Fatalf("ctrl+c sent the waiting message it should drop: %q", agent.sent) + t.Fatalf("esc sent the waiting message it should drop: %q", agent.sent) } if len(a.parks) != 0 { - t.Fatalf("ctrl+c left the waiting message behind: %+v", a.parks) + t.Fatalf("esc left the waiting message behind: %+v", a.parks) } } -// The issue's verification matrix names both queues. One ctrl+c clears the +// The issue's verification matrix names both queues. One esc clears the // session follow-up mirror and the editable parked queue, and neither stream // close may resurrect a turn from either one. -func TestCtrlCClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { +func TestEscClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { a, agent := streaming(t, "reading the tree. ") parkLine(t, a, "the parked message") a.follows = append(a.follows, queued{text: "the queued follow-up"}) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.parks) != 0 || len(a.follows) != 0 { - t.Fatalf("ctrl+c left queues behind: parked=%+v queued=%+v", a.parks, a.follows) + t.Fatalf("esc left queues behind: parked=%+v queued=%+v", a.parks, a.follows) } agent.finish() drive(t, a, streamClosedMsg{gen: a.gen}) @@ -334,16 +334,16 @@ func TestCtrlCClearsBothWaitingQueuesWithoutAnOrphanedTurn(t *testing.T) { } } -// CTRL+C WITH NOTHING WAITING IS EXACTLY WHAT IT WAS: a stop, and no message. -func TestCtrlCWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { +// ESC WITH NOTHING WAITING IS EXACTLY WHAT IT WAS: a stop, and no message. +func TestEscWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { a, agent := streaming(t, "reading the tree. ") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { - t.Fatalf("ctrl+c did not stop the answer: %d stops", agent.stops) + t.Fatalf("esc did not stop the answer: %d stops", agent.stops) } if len(agent.sent) != 1 { - t.Fatalf("ctrl+c sent something nobody typed: %q", agent.sent) + t.Fatalf("esc sent something nobody typed: %q", agent.sent) } if a.state != stateInterrupted { t.Fatalf("state = %v, want interrupted", a.state) @@ -351,15 +351,15 @@ func TestCtrlCWithNothingWaitingIsStillJustTheInterrupt(t *testing.T) { } // H4: the empty running line is the plain interrupt, while a parked message -// says that ctrl+c drops the waiting words as it stops. +// says that esc drops the waiting words as it stops. func TestTheHintSaysWhatEscDoesWhileAMessageIsWaiting(t *testing.T) { a, _ := streaming(t, "reading the tree. ") - if got := a.hintWord(); got != "ctrl+c interrupt" { + if got := a.hintWord(); got != "esc interrupt" { t.Fatalf("a plain running turn = %q, want the interrupt", got) } parkLine(t, a, "no, the other file") - if got := a.hintWord(); got != "ctrl+c stops and drops" { - t.Fatalf("hint = %q, want what ctrl+c now does", got) + if got := a.hintWord(); got != "esc stops and drops" { + t.Fatalf("hint = %q, want what esc now does", got) } } diff --git a/internal/tui3/pastechip_test.go b/internal/tui3/pastechip_test.go index 05edc57a7..6b0bc6f3a 100644 --- a/internal/tui3/pastechip_test.go +++ b/internal/tui3/pastechip_test.go @@ -44,7 +44,7 @@ func TestSmallAndSlashPastesStayTextAndBackspaceDropsAChip(t *testing.T) { if got := a.input.String(); got != "one\ntwo" { t.Fatalf("small paste became %q", got) } - a.input.setText("/ask ") + a.input.setText("/task ") a.paste("one\ntwo\nthree") if strings.Contains(a.input.String(), pasteTokenHead) { t.Fatalf("slash paste became a chip: %q", a.input.String()) diff --git a/internal/tui3/payload.go b/internal/tui3/payload.go index 58a7856fe..1e09ddb95 100644 --- a/internal/tui3/payload.go +++ b/internal/tui3/payload.go @@ -226,7 +226,7 @@ func paintPayload(line string, facts []segment, pal palette, prose func(string) // // The legend's right end and the mode lines under it are written in one shape // at every call site that fills them (render.go's [app.hintWord] lists them -// all): `chord verb`, joined by " · ". `ctrl+c interrupt`. `y allow · n deny · a +// all): `chord verb`, joined by " · ". `esc interrupt`. `y allow · n deny · a // always`. `ctrl+r reveal · ctrl+y copy · esc`. THE CHORD IS THE PAYLOAD and // the verb is the prose, every time, which is a rule worth reading off the // string rather than making twenty-five call sites carry a list of their own diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index 726ca2fea..a586e8df7 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -202,7 +202,7 @@ func TestTheLegendsChordReadsAboveItsExplanation(t *testing.T) { a.state = stateWorking line := a.hintRow(a.width) - if !lifted(a.pal, line, "ctrl+c") { + if !lifted(a.pal, line, "esc") { t.Fatalf("the keys row draws its chord at the weight of its prose:\n%q", line) } if !dimmed(a.pal, line, " interrupt") { @@ -243,7 +243,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { } cases := []hintGrammarCase{ {"drag to select · any key ends it", nil}, - {"ctrl+c interrupt", []string{"ctrl+c"}}, + {"esc interrupt", []string{"esc"}}, {pickerKeysSwitch, []string{"enter", "esc"}}, // The picker's slot follows its cursor (palette.go's [picker.keysHint]). {pickerKeysModel, []string{"→", "alt+s", "enter", "ctrl+t", "esc"}}, @@ -273,8 +273,8 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { // chip the card is actually drawing. {"a accept · n not right · s tell it · esc", []string{"a", "n", "s", "esc"}}, {"esc stops and sends", []string{"esc"}}, - {"opt+e effort · opt+a approvals · opt+k chats · / commands · esc back", - []string{"opt+e", "opt+a", "opt+k", "/", "esc"}}, + {"opt+e effort · opt+a approvals · opt+k chats · / commands · space space home", + []string{"opt+e", "opt+a", "opt+k", "/", "space", "space"}}, {"enter open where it was asked · p pause · s stop · n not here · esc", []string{"enter", "p", "s", "n", "esc"}}, {"enter open · ctrl+r reveal · ctrl+y copy · esc", @@ -297,7 +297,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { } for _, prefix := range runPrefixes { for _, background := range []bool{false, true} { - for _, stop := range []string{"ctrl+c interrupt", parkedHint[1]} { + for _, stop := range []string{"esc interrupt", parkedHint[1]} { parts := []string{} want := append([]string(nil), prefix.want...) if prefix.hint != "" { @@ -308,7 +308,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { want = append(want, "ctrl+g") } parts = append(parts, stop) - want = append(want, "ctrl+c") + want = append(want, "esc") cases = append(cases, hintGrammarCase{hint: strings.Join(parts, hintSegment), want: want}) } } diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index d55b92826..5e6037753 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -536,8 +536,8 @@ func (placeHome) key(a *app, msg tea.KeyPressMsg) tea.Cmd { // // The phone tier's sheet over the inbox is the first (homesheet.go). The second // is a FOCUSED ERRAND: while it holds the keyboard, `tab` hands it back to the -// list; `esc` also returns there while preserving a half-typed follow-up. This -// is the zone law homeexchange.go states in full — a `tab` the router took first +// list and `esc` clears a half-typed follow-up before it does, which is the two- +// zone law homeexchange.go states in full — and a `tab` the router took first // would walk the person out of home mid-sentence. // // THE KEYBOARD IS SETTLED BEFORE THE KEY IS READ. An exchange holds it only diff --git a/internal/tui3/place_memory.go b/internal/tui3/place_memory.go index 133cf4909..cdcac06d8 100644 --- a/internal/tui3/place_memory.go +++ b/internal/tui3/place_memory.go @@ -601,7 +601,8 @@ func (a *app) memoryKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return nil } - return a.openHome() + a.leavePlace() + return nil case "enter": // ONE SPELLING OF WHAT `enter` DOES HERE, and it is the interface's // ([placeMemory.enter]). This arm held a second copy of it, which is how diff --git a/internal/tui3/place_search.go b/internal/tui3/place_search.go index 590c3e9f2..e4e1d62fb 100644 --- a/internal/tui3/place_search.go +++ b/internal/tui3/place_search.go @@ -261,7 +261,8 @@ func (a *app) searchKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return a.searchAsked() } - return a.openHome() + a.leavePlace() + return nil case "up", "ctrl+p": a.moveSearch(-1) a.touch() diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 5e5a55863..c2338c2da 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -961,7 +961,8 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.taskSheetTyped() return nil, true } - return a.openHome(), true + a.leavePlace() + return nil, true case taskSheetKey: // The chord that opened this is the chord that closes it — the roster's own // bargain with alt+t — and it closes it from inside a filter as well, @@ -1240,7 +1241,7 @@ func (a *app) taskSheetPress(x, y int) tea.Cmd { if y < 0 || y >= len(hits) { return nil } - // On a compact frame the foot is an `esc home` band, so a press + // On a compact frame the foot is an `esc close` band, so a press // on it is the way out (taskphone.go). if hits[y].kind == taskSheetHitBar { return a.taskSheetBarPress(x) @@ -1582,7 +1583,7 @@ func (p *tasksPlace) hint(a *app) string { // press. What is true there is the way out, and [placeTailed] puts `tab next // place` in front of it. if !p.detailOn && a.tasksFiltered().held == 0 { - return homeDoorWord + return mapCloseWords } var parts []string // THE CONVERSATION'S OWN CLAUSE, and it is the word this surface already uses @@ -1648,7 +1649,7 @@ func (a *app) tasksPageKeys(parts []string) []string { if a.taskSheetFiltering() { return append(parts, tasksClearFilterWord) } - return append(parts, tasksFilterHint, homeDoorWord) + return append(parts, tasksFilterHint, mapCloseWords) } func (a *app) taskSheetKeysLine() string { return a.taskSheet.hint(a) } diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index e2c75248e..a7f99df57 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -180,14 +180,7 @@ func (placeSettings) note(a *app, width int) []string { return []string{" " + pal.dim(noteFit(a.sheet.footNote(), width-2))} } -func (placeSettings) hint(a *app) string { - hint := a.sheet.keysLine() - if !a.sheetLayerOwnsKeys() && !a.sheet.searching() && - !(a.sheet.onConnections() && (a.sheet.conn.armed || a.sheet.conn.expanded != "")) { - hint = strings.ReplaceAll(hint, "esc close", homeDoorWord) - } - return hint -} +func (placeSettings) hint(a *app) string { return a.sheet.keysLine() } // key is the panel's own grammar (settings.go's [app.sheetKey]): the value being // edited, the model picker, the section bar, and the search across all of them. diff --git a/internal/tui3/place_spend.go b/internal/tui3/place_spend.go index eb7f87c1a..276420de8 100644 --- a/internal/tui3/place_spend.go +++ b/internal/tui3/place_spend.go @@ -512,7 +512,8 @@ func (a *app) spendKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return nil } - return a.openHome() + a.leavePlace() + return nil case "up", "ctrl+p": a.moveSpend(-1) a.touch() @@ -956,11 +957,11 @@ func (placeSpend) hint(a *app) string { } if len(parts) == 0 { // A PAGE WITH NOTHING ON IT STILL HAS A WAY OUT, and that is all it has. - // [placeTailed] adds `tab next place`, so this is `esc home` rather than + // [placeTailed] adds `tab next place`, so this is `esc close` rather than // a foot naming three keys over an empty ledger. - return homeDoorWord + return mapCloseWords } - return strings.Join(parts, railSep) + railSep + homeDoorWord + return strings.Join(parts, railSep) + railSep + mapCloseWords } func (placeSpend) press(a *app, y int) (tea.Cmd, bool) { diff --git a/internal/tui3/place_standing.go b/internal/tui3/place_standing.go index 897d1f4b1..90c76febc 100644 --- a/internal/tui3/place_standing.go +++ b/internal/tui3/place_standing.go @@ -697,7 +697,8 @@ func (a *app) standingPlaceKey(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch msg.String() { case "esc": - return a.openHome() + a.leavePlace() + return nil // THE CURSOR WALKS THE ROWS THE BODY WAS PAINTED FROM, and there is one such // list ([app.standingPageRows]). Three arithmetics over three lists — one // clamping into the orders, one resolving against a shorter fold, one diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index 7158a6e59..238beeed6 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -54,7 +54,7 @@ func TestTheTasksFootIsScreenOneEWordForWord(t *testing.T) { // sorting is a chord, and a chord nobody can find is a chord that does not // exist. The filter is named beside it because nothing else on the frame says // that a letter goes into the box on the control row rather than to the page. - const want = "enter open its room · → verbs: close, open folder, copy project, stop it · type to filter · esc home" + const want = "enter open its room · → verbs: close, open folder, copy project, stop it · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } @@ -93,7 +93,7 @@ func TestTheTasksFootSaysOnlyWhatIsTrueOfTheRowUnderIt(t *testing.T) { if !ok || item.entry.Title != "Port the parser" { t.Fatalf("the walk did not reach the earlier conversation's row: %+v", item) } - const want = "enter go inside it · type to filter · esc home" + const want = "enter go inside it · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("over work another conversation ran the foot reads\n %q\nwant\n %q", got, want) } @@ -205,7 +205,7 @@ func TestTheTasksFootNamesNoStopWithoutTheEnginesDoor(t *testing.T) { // The cursor's row is a family, and [openTaskPlaceWithRows] has opened it — // so the fold clause is the `←` half. What this test is about is what is NOT // here: no stop verb, on a session with no door onto stopping. - const want = "enter open its room · ← fold it back up · → verbs: open folder, copy project · type to filter · esc home" + const want = "enter open its room · ← fold it back up · → verbs: open folder, copy project · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } diff --git a/internal/tui3/placehint_test.go b/internal/tui3/placehint_test.go index 4051b7c71..66c25482c 100644 --- a/internal/tui3/placehint_test.go +++ b/internal/tui3/placehint_test.go @@ -159,9 +159,9 @@ func TestTheSpendFootNamesTheKeysAPersonWouldPress(t *testing.T) { if strings.Contains(bare, spendEnterWord) { t.Errorf("the spend foot promises %q over a ledger with no rows: %q", spendEnterWord, bare) } - if bare != placeHintTail+" · esc home" { + if bare != placeHintTail+" · esc close" { t.Errorf("the spend foot over an empty ledger drew %q, want %q — the way out is said last and said once", - bare, placeHintTail+" · esc home") + bare, placeHintTail+" · esc close") } // A row that opens something: enter, the one verb, and the window. diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 91de722f4..a7840e1e5 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -76,6 +76,11 @@ func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { if pl == nil { return nil } + // THE BARE DOOR DISARMS ON ANY KEY BUT THE SECOND SPACE, read before anything + // can take the key ([app.placeHomeGesture] re-arms it on a first space). + if msg.Key().Text != " " { + a.placeSpaceArmed = false + } // THE ROUTER'S OWN LAYER IS READ BEFORE THE PLACE'S, and it is the only thing // on this surface that is. The composer layer belongs to no place — the three // facts it settles are the same three wherever the sentence was typed — so a @@ -92,6 +97,16 @@ func (a *app) placeKeyPress(msg tea.KeyPressMsg) tea.Cmd { if cmd, took := a.placeKey(msg); took { return cmd } + // THE DOOR HOME IS READ HERE, at the bottom of the place router, because it + // must lose to every other meaning a space could have where a person is + // standing: the whole-keyboard layers above it, the router's six classes, and + // every key a place claims for its own rows. What is left — a plain space + // falling toward the place's box — is exactly what the door is made of + // ([app.placeHomeGesture]). It stands beside the conversation's own reading + // at the bottom of [app.key], one law about the box, two doors in. + if cmd, took := a.placeHomeGesture(msg); took { + return cmd + } return pl.key(a, msg) } @@ -395,6 +410,53 @@ func (a *app) placeBox() *editor { return pl.box(a) } +// placeHomeGesture is the door home read from WHATEVER PLACE IS STANDING: two +// spaces typed into that place's own box, the same two keystrokes that open it +// from inside a conversation (home.go's [app.homeGesture]). +// +// THE DOOR USED TO BE A CONVERSATION'S DOOR ONLY. The gesture lived at the +// bottom of [app.key], past the rung where a standing place takes the whole +// keyboard — so a place never saw the check, and a person standing on the +// search place, or the tasks place, or settings, had `space space` die under +// their hands while the tab bar sat one walk away. The person's words were +// `universal`, and this is what makes it so: the same guard +// ([app.homeDoorOpen] — anywhere but home itself, where the gesture is a no-op +// and the foot draws nothing), the same law about the box +// ([app.homeDoorArmed]), the same box the place was already typing into +// ([app.placeBox]). +// +// A PLACE WITH NO BOX HAS NO DOOR, and that is right rather than a gap: the +// gesture is a thing typed into a box, and where there is no box the key does +// nothing and always did. The box is RESET before home opens, because the two +// spaces were two spaces somebody typed and not a draft anybody meant to keep +// — the conversation's door empties its draft the same way ([app.key]). +func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { + if !a.homeDoorOpen() { + return nil, false + } + box := a.placeBox() + // A PLACE WITH NO BOX STILL HAS THE DOOR. Spend and standing type into + // nothing (pages.go's [place.box]), so the two spaces are counted here + // rather than read back out of an editor: the first arms, the second + // opens, and any other key in between disarms ([app.placeKeyPress]). + if box == nil { + if msg.Key().Text != " " { + return nil, false + } + if a.placeSpaceArmed { + a.placeSpaceArmed = false + return a.openHome(), true + } + a.placeSpaceArmed = true + return nil, true + } + if !a.homeDoorArmed(box, msg) { + return nil, false + } + box.reset() + return a.openHome(), true +} + // placeSend is `alt+enter` over a composer with something in it: THE COMPOSER // LAYER OPENS, and a second press is what sends (composerlayer.go, SCREEN 2e). // diff --git a/internal/tui3/placemsgline_test.go b/internal/tui3/placemsgline_test.go index d0ca3ef46..5fa396bd6 100644 --- a/internal/tui3/placemsgline_test.go +++ b/internal/tui3/placemsgline_test.go @@ -72,8 +72,8 @@ func TestANotelessFootKeepsTheDoorHintAsToday(t *testing.T) { a, _ := tasksFootApp(t) a.width, a.height = 100, 30 line := msgFootLine(a) - if line != "enter open its room · tab next place · esc home" { - t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · tab next place · esc home") + if line != "enter open its room · tab next place · esc close" { + t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · tab next place · esc close") } if strings.Contains(line, "waiting in this conversation") { t.Fatalf("a task that raised nothing drew a waiting note: %q", line) diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 238ce22dd..65df94751 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -1770,7 +1770,7 @@ func (a *app) compactRow(e *entry, width int) string { // The bottom of this surface is TWO ROWS, and every element on them has exactly // one job (foot.go states the whole law): // -// ─ porting the parser · gpt-4.1-mini · ⠿ high · ◇ asks · via deepinfra ──── esc back · / commands ─ +// ─ porting the parser · gpt-4.1-mini · ⠿ high · ◇ asks · via deepinfra ──── space space home · / commands ─ // $0.14 · ⟲ saved $0.02 · 89% cached 12.4k/128k · 10% 2 jobs 92 tok/s · ⠹ working · 4s // // THE SEAM IS IDENTITY — which conversation, what is answering it, and the keys @@ -3503,12 +3503,12 @@ func (a *app) legendLinePainted(left, right, rightPainted string, width int, pai // a bug the breadcrumb bar exposed: this line is laid out by the pinned // header as well as by the legend (room.go, roomcrumbs.go), and the header is // drawn AFTER the chrome — so a header clearing the span erased a door the - // legend had just recorded, and `esc back` became a label nothing + // legend had just recorded, and `space space home` became a label nothing // answered for. What makes the span its own answer to "was it drawn" is // [app.legend] clearing it before its own ladder starts. - if offset := strings.Index(right, a.escapeDoorWord()); offset >= 0 { + if offset := strings.Index(right, homeDoorWord); offset >= 0 { from := at + ansi.StringWidth(right[:offset]) - a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(a.escapeDoorWord())} + a.homeDoor = hudSpan{from: from, to: from + ansi.StringWidth(homeDoorWord)} } line := a.pal.dim("─") if left != "" { @@ -3716,7 +3716,7 @@ func (a *app) idleHint() string { } doors = append(doors, microcopy) if a.homeDoorShowing() { - doors = append(doors, a.escapeDoorWord()) + doors = append(doors, homeDoorWord) } return strings.Join(doors, hintSegment) } @@ -3752,6 +3752,7 @@ const hopDoorWord = hopOpenKey + " chats" // inside a fold enter choose · ← back · esc · crew max // the sessions are up enter open · esc // copy mode is on v select · a block · y yank · esc +// rewind is armed esc again to rewind (rewind.go's double esc) // rewind mode is up nothing — the mode bar prints its own keys // the welcome box is up ↑↓ recent · enter open // a path is completing tab take · enter run · esc @@ -3850,6 +3851,16 @@ func (a *app) hintWord() string { // box (rewind.go), and a slot repeating them would be the surface saying // the same thing twice on one screen. return "" + case a.rewindArmed() && a.rewindReady(): + // The first esc has landed and the second one means something else for + // half a second. This outranks "esc interrupt" below for exactly that + // reason: while the window is open, that is no longer what the key does. + // + // It asks [app.rewindReady] as well as the clock, because the two can come + // apart: a question can be raised in the half second the window is open, + // and from that moment esc belongs to the question. The slot promises what + // the NEXT esc does, so it has to ask the same thing that key will. + return rewindArmWord case a.rewindSaying(): return a.rewSay case a.welcome.open: @@ -3898,7 +3909,7 @@ func (a *app) hintWord() string { // everything the draft would have got. And it ranks ABOVE the two lines // below for the reason this whole slot is ordered the way it is — while a // room is open, esc leaves the page and does not touch the conversation's - // turn, so "ctrl+c interrupt" would be naming a key that is spoken for. + // turn, so "esc interrupt" would be naming a key that is spoken for. return a.roomHint() case a.state == stateWorking: // ONE RUNNING STATE, ONE COMPOSED LINE (steer.go's [app.runHint]). Its diff --git a/internal/tui3/rewind.go b/internal/tui3/rewind.go index 00f98efff..892752c00 100644 --- a/internal/tui3/rewind.go +++ b/internal/tui3/rewind.go @@ -38,8 +38,14 @@ import ( // could enter without first saving their own work somewhere else, and esc puts // the sentence back exactly as it was. // -// Escape is reserved for back navigation. /rewind opens the timeline; this -// inline renderer remains available to internal callers. +// THE DOOR IS DOUBLE-ESC, and it is double for one reason: esc already means +// INTERRUPT while a turn runs, and that meaning is not for sale. So the first esc +// keeps whatever it always meant — it stops the model mid-turn, and at rest it +// does nothing at all — and it ARMS this mode for [rewindArmWindow]; a second esc +// inside that window opens it. Interrupt first, then rewind, in the order the +// engine's own refusal asks for ([session.ErrTurnInFlight]): the pair is one +// gesture, and it is the gesture a person's hand already makes when they want to +// take something back. // // KEYS: ↑/↓ walk the TURNS, which is the unit a person thinks in ("not that // message"); ←/→ slide through the steps inside the turn the cut is in, for the @@ -56,6 +62,23 @@ import ( // one of them to be derived from the other — which is exactly what the resume // path already does. +// rewindArmWindow is how long the first esc keeps rewind armed. +// +// HALF A SECOND, and the number is chosen against the HAND rather than against a +// reaction time. A deliberate double-tap — the double-click every pointer on this +// machine is calibrated for, and the "esc esc" that leaves an editor's mode — +// lands its second key inside 300ms; a person who pressed esc to interrupt a turn +// and then decided, having read something, to also take it back is a person +// making a second decision, and their second key arrives a second or more later. +// So the window has to be long enough that the first kind never misses and short +// enough that the second kind never fires by accident. +// +// It errs SHORT on purpose. A window that lapsed too early costs one extra +// keystroke; a window that lapsed too late puts a person who tapped esc twice to +// dismiss two things into a mode they did not ask for, over a conversation they +// are about to cut. The two mistakes are not the same size. +const rewindArmWindow = 500 * time.Millisecond + // rewindSayWindow is how long a sentence this mode could not act on stays in the // hint slot — "nothing to rewind", and nothing else. Two and a half seconds is // long enough to be read once and short enough that it is never furniture. @@ -63,6 +86,8 @@ const rewindSayWindow = 2500 * time.Millisecond // The mode's words, written down once. const ( + // rewindArmWord is what the hint slot says while the first esc is still warm. + rewindArmWord = "esc again to rewind" // rewindEmptyWord is what it says when there is nothing to cut. rewindEmptyWord = "nothing to rewind" // rewindCutWord is the cut line's label, and rewindKeysWord the mode bar's @@ -149,6 +174,7 @@ func (a *app) enterRewind() tea.Cmd { if len(points) == 0 { return a.sayRewind(rewindEmptyWord) } + a.disarmRewind() a.closeLists() a.dropHover() a.rew = rewindMode{ @@ -196,10 +222,64 @@ func (a *app) leaveRewind(restore bool) { a.touch() } +// ── THE DOUBLE ESC ────────────────────────────────────────────────────────── + +// escRewind is esc's rewind half. It reports whether it TOOK the key: the second +// esc inside the window opens the mode and is taken, and the first one arms and +// is NOT — it goes on to mean whatever it always meant, which mid-turn is the +// interrupt (input.go's esc case). The command it returns is carried down both +// paths, because arming needs the frame clock to run the window down. +func (a *app) escRewind() (tea.Cmd, bool) { + if !a.rewindReady() { + return nil, false + } + if a.rewindArmed() { + return a.enterRewind(), true + } + a.escArm = a.now() + a.touch() + return a.wake(), false +} + +// rewindReady reports whether esc may arm the mode at all: nothing else on this +// surface is holding the keyboard, and the agent under it can rewind. +// +// It is the LIST OF STATES esc already means something in, read from the routers +// that take the key before input.go's own switch does (input.go, app.go's Update) +// — a modal state whose dismiss key silently armed a second mode would be a +// surface where esc means two things at once. +func (a *app) rewindReady() bool { + if a.rew.on { + return false + } + if _, ok := a.rewinder(); !ok { + return false + } + switch { + case a.rewSheet.open, + a.at(pageSettings), a.at(pageTasks), a.deck.open, a.expand.open, a.pick.open, a.roster.open, + a.connPanel.open, a.menu.open, a.comp.open, a.welcome.open, + a.copy.on, a.recalling(), a.roomOpen(), a.railHold, a.railFull(), + a.asking(), a.awaitingTask(), a.guard != nil, a.asksConnect(), + a.asksHarness(): + return false + } + return true +} + +// rewindArmed reports whether the first esc is still warm. +func (a *app) rewindArmed() bool { + return !a.escArm.IsZero() && a.now().Sub(a.escArm) < rewindArmWindow +} + +// disarmRewind forgets it. +func (a *app) disarmRewind() { a.escArm = time.Time{} } + // sayRewind puts one sentence in the hint slot for a moment — "nothing to // rewind", which is the whole of what this is for. func (a *app) sayRewind(text string) tea.Cmd { a.rewSay, a.rewSayAt = text, a.now() + a.disarmRewind() a.touch() return a.wake() } @@ -211,13 +291,17 @@ func (a *app) rewindSaying() bool { // rewindTicking says the frame clock has a reason to keep turning even with // nothing else happening: a window or a sentence with an end to reach. -func (a *app) rewindTicking() bool { return a.rewindSaying() } +func (a *app) rewindTicking() bool { return a.rewindArmed() || a.rewindSaying() } // rewindSweep runs both of those clocks down. It is called from [app.paint] and // from nowhere else — NO GOROUTINE OF ITS OWN, for the reason the spinner and the // countdowns have none (app.go): this surface has exactly one clock, and a second // one is a second wakeup per second and two states that disagree about the time. func (a *app) rewindSweep() { + if !a.escArm.IsZero() && !a.rewindArmed() { + a.disarmRewind() + a.touch() + } if a.rewSay != "" && !a.rewindSaying() { a.rewSay, a.rewSayAt = "", time.Time{} a.touch() diff --git a/internal/tui3/rewind_test.go b/internal/tui3/rewind_test.go index 159dcc9af..8af385ab1 100644 --- a/internal/tui3/rewind_test.go +++ b/internal/tui3/rewind_test.go @@ -102,6 +102,90 @@ func rewindRowY(a *app, want string) (int, bool) { // ── 1. the door ───────────────────────────────────────────────────────────── +// The first esc arms and says so; the second one inside the window opens the +// mode over the conversation. +func TestEscTwiceInsideTheWindowEntersRewind(t *testing.T) { + a, _ := newRewindApp(t, rewindPast()) + + drive(t, a, key("esc")) + if !a.rewindArmed() { + t.Fatal("the first esc did not arm the rewind") + } + if a.rew.on { + t.Fatal("one esc entered the mode") + } + if got := a.hintWord(); got != rewindArmWord { + t.Fatalf("hint slot = %q, want %q", got, rewindArmWord) + } + if got := plain(frame(a)); !strings.Contains(got, rewindArmWord) { + t.Fatalf("the armed frame does not say so:\n%s", got) + } + + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc did not enter the mode") + } +} + +// A window that lapses takes the meaning with it: the next esc is an ordinary +// esc, and the mode stays shut. +func TestTheArmLapsesAndAStrayEscChangesNothing(t *testing.T) { + a, _ := newRewindApp(t, rewindPast()) + now := time.Now() + a.clock = func() time.Time { return now } + + drive(t, a, key("esc")) + if !a.rewindArmed() { + t.Fatal("the first esc did not arm the rewind") + } + + now = now.Add(rewindArmWindow + time.Millisecond) + // The frame clock is what runs the window down — no goroutine of its own. + drive(t, a, frameMsg{}) + if a.rewindArmed() || !a.escArm.IsZero() { + t.Fatal("the arm survived its window") + } + if got := a.hintWord(); got == rewindArmWord { + t.Fatal("the hint slot still offers a rewind after the window lapsed") + } + + drive(t, a, key("esc")) + if a.rew.on { + t.Fatal("a stray esc after the window entered the mode") + } +} + +// Mid-turn the first esc keeps its own meaning — it stops the model — and the +// second one inside the window still opens the mode. Interrupt first, then +// rewind, which is the order the engine's refusal asks for. +func TestEscMidTurnInterruptsFirstAndThenRewinds(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + + drive(t, a, key("esc")) + if agent.stops != 1 { + t.Fatalf("the first esc mid-turn did not interrupt (%d)", agent.stops) + } + if !a.rewindArmed() { + t.Fatal("the first esc mid-turn did not arm the rewind") + } + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc mid-turn did not enter the mode") + } +} + +// A backend that cannot rewind simply has no rewind: esc means what it always +// meant, twice. +func TestASurfaceWithoutARewinderNeverArms(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + drive(t, a, key("esc"), key("esc")) + if a.rewindArmed() || a.rew.on { + t.Fatal("a surface with no rewind door opened one") + } +} + // ── 2. the mode ───────────────────────────────────────────────────────────── // The door a /rewind command calls opens the same mode the keys do: a cut line diff --git a/internal/tui3/rewindsheet.go b/internal/tui3/rewindsheet.go index 10ea5d864..b419ac0fb 100644 --- a/internal/tui3/rewindsheet.go +++ b/internal/tui3/rewindsheet.go @@ -25,9 +25,14 @@ import ( // ⟲ drops 1 turn — everything below the pick is let go // esc close · ↑↓ move · enter picks the point // -// /rewind opens this timeline explicitly. Escape only backs out; it never -// enters a rewind mode. The full history stays reachable here even when the -// transcript view has loaded only its most recent blocks. +// THERE ARE TWO REWINDS AND THEY ANSWER TWO DIFFERENT QUESTIONS. esc esc opens +// the INLINE mode (rewind.go), which is the quick take-back: the transcript on +// screen is the picker, the answer is almost always "the thing I just said", and +// the whole gesture is over in two keystrokes. This page is the DELIBERATE one — +// "take me back to before we started down this road" — and the question it +// answers cannot be answered by the inline mode at all, because the inline mode +// walks the DRAWN blocks and a resumed conversation draws only its last +// [replayTail] entries. Everything older than that was unreachable. // // SO THIS PAGE IS BUILT FROM THE SESSION AND NOT FROM THE SCREEN. Its rows come // out of [session.Agent.Transcript] and its points out of @@ -202,6 +207,7 @@ func (a *app) openRewindSheet() tea.Cmd { if len(points) == 0 { return a.sayRewind(rewindEmptyWord) } + a.disarmRewind() a.closeLists() a.dropHover() // THE OTHER FULLSCREEN PAGES STAND DOWN, which is the law settings.go states: diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 141357dc5..24429cf07 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1911,8 +1911,13 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } switch msg.String() { case "esc": - // Escape backs out without stopping work, as it does in the main - // conversation. Stopping a task remains an explicit x and confirmation. + // ESC IN HERE IS THE DOOR AND IT IS NEVER A STOP — stop.go's standing law, + // restated at the keystroke it is about. Out in the conversation esc + // interrupts the running turn; the analogous act in a room is ending the + // node, which is not reversible and is therefore always asked first (`x`, + // and the card). So the two surfaces do NOT converge on this key, and the + // legend says which of the two meanings is live: while a room is open the + // hint slot never reads "esc interrupt" (render.go's [app.hintWord]). // // A recall walk is left first, for the reason input.go leaves it first: a // state that could not be dismissed by the dismiss key is a trap, and the @@ -2010,7 +2015,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // roomHint is the hint slot while a room is open (render.go's [app.hintWord]), // and it exists because that slot used to LIE in here: with a turn running out -// in the conversation it drew "ctrl+c interrupt" over a page where esc leaves the +// in the conversation it drew "esc interrupt" over a page where esc leaves the // room and interrupts nothing. A hint naming a key that does something else is // the one failure the slot exists to prevent. // @@ -2041,7 +2046,7 @@ func (a *app) roomHint() string { return "/model · /stop · esc main" } // THE ROOM'S ANSWER TO "HOW DO I STOP THIS". It is the honest counterpart - // to the conversation's "ctrl+c interrupt": the work in here ends through a + // to the conversation's "esc interrupt": the work in here ends through a // card and never through the dismiss key (stop.go), so this is the key a // person reaching for esc actually wants. It is drawn only while there is // something to stop, which is the emptiness law applied to a hint. diff --git a/internal/tui3/roomrecall_test.go b/internal/tui3/roomrecall_test.go index 7840064a3..ea4b98617 100644 --- a/internal/tui3/roomrecall_test.go +++ b/internal/tui3/roomrecall_test.go @@ -157,13 +157,13 @@ func TestUpScrollsTheRoomWhenThereIsNoHistory(t *testing.T) { } } -// THE HINT SLOT MUST NOT SAY "ctrl+c interrupt" IN A ROOM, because esc in here +// THE HINT SLOT MUST NOT SAY "esc interrupt" IN A ROOM, because esc in here // leaves the page and interrupts nothing (room.go's [app.roomHint]). func TestTheHintSlotInARoomNeverPromisesAnInterrupt(t *testing.T) { a, _ := roomRecallApp(t) a.state = stateWorking - if hint := a.hintWord(); hint == "ctrl+c interrupt" { + if hint := a.hintWord(); hint == "esc interrupt" { t.Fatal("the hint slot promised an interrupt from inside a room") } // What it says instead is the key that actually ends the work in here. @@ -171,7 +171,7 @@ func TestTheHintSlotInARoomNeverPromisesAnInterrupt(t *testing.T) { t.Fatalf("the room's hint reads %q, want %q", hint, roomStopHint) } drive(t, a, key("esc")) - if hint := a.hintWord(); hint != "ctrl+c interrupt" { + if hint := a.hintWord(); hint != "esc interrupt" { t.Fatalf("back in the conversation the hint reads %q", hint) } } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d3387210e..91208c378 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -2110,7 +2110,8 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if a.connEsc() { return nil, true } - return a.openHome(), true + a.closeSettings() + return nil, true // ← AND → MOVE THIS PANEL'S OWN SECTIONS, and `tab` no longer does. `tab` is // the way to the NEXT PLACE now (pages.go), and a key that meant "next diff --git a/internal/tui3/slashchip.go b/internal/tui3/slashchip.go index 6e59db083..d20857d03 100644 --- a/internal/tui3/slashchip.go +++ b/internal/tui3/slashchip.go @@ -50,7 +50,6 @@ const ( sendDoorNone sendDoor = iota sendDoorStanding sendDoorTask - sendDoorAsk ) const ( @@ -214,9 +213,6 @@ func (a *app) slashTagHint() string { if commandDoor(word) == sendDoorStanding { return slashTagHintStanding } - if commandDoor(word) == sendDoorAsk { - return "" - } return slashTagHintTask } diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 3e02018d1..c056faaa9 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -462,7 +462,7 @@ func (a *app) runSendOffered() bool { func (a *app) runHint() string { if a.questionWriting() { // WHILE THE BOX IS A QUESTION'S, THE QUESTION'S ROW IS THE HINT. - // `enter steers it in · ctrl+c interrupt` over a box whose enter answers + // `enter steers it in · esc interrupt` over a box whose enter answers // a card and whose esc gives the box back would be two keys named // wrong on one screen ([app.questionWritingRow] says them right). return "" @@ -474,7 +474,7 @@ func (a *app) runHint() string { if a.promotableRow() >= 0 { parts = append(parts, "ctrl+g backgrounds") } - stop := "ctrl+c interrupt" + stop := "esc interrupt" if len(a.parks) > 0 && a.parking() { stop = parkedHint[1] } diff --git a/internal/tui3/steer_test.go b/internal/tui3/steer_test.go index 3b64e4ef9..61826a163 100644 --- a/internal/tui3/steer_test.go +++ b/internal/tui3/steer_test.go @@ -583,7 +583,7 @@ func TestTheHintUnderTheBoxTeachesTheSteerWhereTheChordCanBeDelivered(t *testing a, _ := steerableTurn(t, "reading the tree. ") typeInto(t, a, "no, the other file") - want := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + " · ctrl+c interrupt" + want := "enter " + steerSendWord + " · " + bargeKey + " " + bargeSendWord + " · esc interrupt" if got := a.hintWord(); got != want { t.Fatalf("the hint slot reads %q, want %q", got, want) } @@ -592,7 +592,7 @@ func TestTheHintUnderTheBoxTeachesTheSteerWhereTheChordCanBeDelivered(t *testing // because steering does not need a modified-key protocol. The unavailable // secondary chords are the only clauses removed. a.keysDisambiguated = false - if got := a.hintWord(); got != steerShortHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != steerShortHint+" · esc interrupt" { t.Fatalf("a basic terminal lost the plain-enter steer: %q", got) } } @@ -604,13 +604,13 @@ func TestAPictureOnTheTrayKeepsThePlainEnterHint(t *testing.T) { a, _ := steerableTurn(t, "reading the tree. ") a.chips = []chip{{path: "/tmp/shot.png"}} - want := enterWaitHint + " · " + bargeKey + " " + bargeSendWord + " · ctrl+c interrupt" + want := enterWaitHint + " · " + bargeKey + " " + bargeSendWord + " · esc interrupt" if got := a.hintWord(); got != want { t.Fatalf("the tray-only hint reads %q, want %q", got, want) } a.keysDisambiguated = false - if got := a.hintWord(); got != enterWaitHint+" · ctrl+c interrupt" { + if got := a.hintWord(); got != enterWaitHint+" · esc interrupt" { t.Fatalf("a basic terminal lost the tray's plain-enter hint: %q", got) } } @@ -651,7 +651,7 @@ func TestANarrowFrameKeepsTheShorterHintRatherThanLosingTheSlot(t *testing.T) { if !strings.Contains(body, steerShortHint) { t.Fatalf("the narrow frame lost the whole hint slot:\n%s", body) } - if strings.Contains(body, whole) || strings.Contains(body, "ctrl+c interrupt") { + if strings.Contains(body, whole) || strings.Contains(body, "esc interrupt") { t.Fatalf("the narrow ladder did not drop from the right:\n%s", body) } } @@ -659,7 +659,7 @@ func TestANarrowFrameKeepsTheShorterHintRatherThanLosingTheSlot(t *testing.T) { // AND THE WAITING MESSAGE'S OWN LINE CARRIES THE ARROW, unconditionally as far // as the terminal is concerned: an arrow key reaches every terminal there is, so // there is nothing to gate the clause on but whether the act itself is possible. -func TestTheStripNamesTheArrowAndCtrlCDropsTheWholeWaitingBlock(t *testing.T) { +func TestTheStripNamesTheArrowAndEscDropsTheWholeWaitingBlock(t *testing.T) { a, agent := steerableTurn(t, "reading the tree. ") a.width = 90 parkLine(t, a, "do much more of a deep research please") @@ -677,7 +677,7 @@ func TestTheStripNamesTheArrowAndCtrlCDropsTheWholeWaitingBlock(t *testing.T) { // ESC drops the queue at the keypress, so a winding-down turn has neither a // stale message nor an arrow that claims it can still cross a boundary. - drive(t, a, key("ctrl+c"), frameMsg{}) + drive(t, a, key("esc"), frameMsg{}) if !a.windingDown() { t.Fatal("the surface is not winding down after esc") } diff --git a/internal/tui3/stopbound_test.go b/internal/tui3/stopbound_test.go index 69f680311..7f89c8020 100644 --- a/internal/tui3/stopbound_test.go +++ b/internal/tui3/stopbound_test.go @@ -48,7 +48,7 @@ func boundedStopApp(t *testing.T) (*app, *abandoningAgent, func(time.Duration)) drive(t, a, submittedMsg{ch: make(chan session.Event)}) a.state = stateWorking a.turnBegan = a.now() - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) return a, agent, func(d time.Duration) { now = now.Add(d) } } @@ -88,7 +88,7 @@ func TestTheStoppingLineSaysWhenItWillDetach(t *testing.T) { // no way to perform. func TestAStopWithNoDoorBehindItAnnouncesNoBound(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if a.stopBounded() { t.Fatal("a surface with no abandon door claims a bound it cannot keep") diff --git a/internal/tui3/stopping_test.go b/internal/tui3/stopping_test.go index 496f79e49..9ab386a2d 100644 --- a/internal/tui3/stopping_test.go +++ b/internal/tui3/stopping_test.go @@ -16,7 +16,7 @@ import ( // loop lets go, which for a `bash` holding a leaked pipe or a `jobs` kill is // three or four seconds, and for the whole of that window the surface went on // drawing what arrived. What is pinned here is the law that closed it: the frame -// after ctrl+c shows no motion, claims "working" nowhere, and draws nothing new +// after esc shows no motion, claims "working" nowhere, and draws nothing new // until the turn is over ([app.windingDown], [keptAfterStop], [stoppingWord]). // stoppingApp is a turn caught mid-flight, with the two things on screen that a @@ -53,20 +53,20 @@ func TestTheFrameStillsOnTheKeyAndClaimsWorkingNowhere(t *testing.T) { t.Fatalf("the turn was not working before the key:\n%s", before) } - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { t.Fatalf("esc did not stop the turn (%d)", agent.stops) } after := plain(frame(a)) if strings.Contains(after, stateWorking.String()) { - t.Fatalf("the frame after ctrl+c still says working:\n%s", after) + t.Fatalf("the frame after esc still says working:\n%s", after) } // THE SPINNER IS THE MOTION, and there are two of it on this frame — the // status line's and the tool row's. Neither cell may survive the key: a still // frame with one thing turning in it is the surface insisting on something // the person has just ended. if strings.ContainsAny(after, spinnerFrames()) { - t.Fatalf("a spinner is still turning after ctrl+c:\n%s", after) + t.Fatalf("a spinner is still turning after esc:\n%s", after) } // AND THE ROW CARRIES ITS OWN END, rather than merely being drawn quietly // because the session happens not to be working. @@ -80,10 +80,10 @@ func TestTheFrameStillsOnTheKeyAndClaimsWorkingNowhere(t *testing.T) { // takes, and gives the word up for the fact the moment the stream closes. func TestTheStatusLineSaysStoppingUntilTheStreamCloses(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if !a.windingDown() { - t.Fatal("the surface is not winding down after ctrl+c") + t.Fatal("the surface is not winding down after esc") } word, painted := a.stateWord() if word != stoppingWord { @@ -116,7 +116,7 @@ func TestTheStatusLineSaysStoppingUntilTheStreamCloses(t *testing.T) { // drew a fresh tool row in the same place, for work that was never going to run. func TestNothingArrivingAfterTheStopIsDrawn(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) was := len(a.entries) said := plain(frame(a)) @@ -144,7 +144,7 @@ func TestNothingArrivingAfterTheStopIsDrawn(t *testing.T) { // of ([keptAfterStop]). func TestALateToolCloseStillLandsOnTheRowItBelongsTo(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) was := len(a.entries) drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventToolEnd, @@ -165,7 +165,7 @@ func TestALateToolCloseStillLandsOnTheRowItBelongsTo(t *testing.T) { // and the usage rides on the two events that end one. func TestTheStoppedTurnStillTakesItsUsage(t *testing.T) { a, _ := stoppingApp(t) - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTurnDone, Usage: session.Usage{Input: 900, Output: 100, CostUSD: 0.25}}}) @@ -177,6 +177,97 @@ func TestTheStoppedTurnStillTakesItsUsage(t *testing.T) { } } +// ── 3. the esc mash ───────────────────────────────────────────────────────── + +// THE EVERYDAY GESTURE: a person mashes esc at a turn they want stopped. The +// first one stops it; the second, inside [rewindArmWindow], opens the rewind +// over a conversation they were not thinking about cutting; the third leaves. +// +// NOTHING DESTRUCTIVE CAN COME OF IT. The mode does nothing without enter, esc +// puts the sentence they were typing back exactly as it was, and the engine is +// never asked to cut anything. +func TestMashingEscStopsTheTurnAndLeavesTheConversationWhole(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + for _, r := range "half a thought" { + drive(t, a, key(string(r))) + } + + drive(t, a, key("esc")) + if agent.stops != 1 { + t.Fatalf("the first esc did not stop the turn (%d)", agent.stops) + } + // Counted AFTER the stop, because the stop writes its own `interrupted` line + // and that line is the one thing a mash is supposed to leave behind. + before := len(a.entries) + drive(t, a, key("esc")) + if !a.rew.on { + t.Fatal("the second esc did not open the rewind") + } + // THE MODE OPENS CALM. Nothing has been cut, and the draft is being held + // rather than spent. + if len(agent.cuts) != 0 { + t.Fatalf("opening the mode cut the conversation at %v", agent.cuts) + } + if got := string(a.rew.draft); got != "half a thought" { + t.Fatalf("the mode is holding %q, want the sentence in the box", got) + } + + drive(t, a, key("esc")) + if a.rew.on { + t.Fatal("the third esc did not leave the mode") + } + if len(agent.cuts) != 0 { + t.Fatalf("mashing esc cut the conversation at %v", agent.cuts) + } + if got := string(a.input.value); got != "half a thought" { + t.Fatalf("the sentence came back as %q", got) + } + if len(a.entries) != before { + t.Fatalf("mashing esc changed the conversation: %d blocks, was %d", len(a.entries), before) + } + // AND THE STOP IS STILL THE STOP. The rewind rode on top of it and took + // nothing from it. + if agent.stops != 1 { + t.Fatalf("the mash stopped the turn %d times", agent.stops) + } +} + +// A MASH THAT KEEPS GOING IS STILL SAFE. Past the third key the pair starts over +// — arm, open, leave — and the sentence survives every round of it. +func TestAnEndlessEscMashNeverCutsAnything(t *testing.T) { + a, agent := newRewindApp(t, rewindPast()) + drive(t, a, submittedMsg{ch: make(chan session.Event)}) + a.state = stateWorking + for _, r := range "keep me" { + drive(t, a, key(string(r))) + } + + for range 9 { + drive(t, a, key("esc")) + } + if len(agent.cuts) != 0 { + t.Fatalf("nine escs cut the conversation at %v", agent.cuts) + } + // The mode is either up holding the sentence or down with it back in the + // box; both are the same promise, and one of the two is always true. + held := string(a.input.value) + if a.rew.on { + held = string(a.rew.draft) + } + if held != "keep me" { + t.Fatalf("the sentence is %q after nine escs", held) + } +} + +// ── 4. the same law in a task's room ──────────────────────────────────────── + +// THE ROOM SAYS THE SAME WORD. Stopping a node is not esc — in a room esc is the +// door and never a stop (stop.go) — but the window after the answer is the +// conversation's window exactly: the child's context is cut and the child is +// winding up, and for the whole of it this header used to read "working" about +// work the person had just ended. func TestAStoppedNodesRoomSaysStoppingRatherThanWorking(t *testing.T) { a, _ := stopApp(t) drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", diff --git a/internal/tui3/stripword_test.go b/internal/tui3/stripword_test.go index 3079ad7a5..23719aa7d 100644 --- a/internal/tui3/stripword_test.go +++ b/internal/tui3/stripword_test.go @@ -108,7 +108,7 @@ func TestATeachingPagesFootOffersNoVerbOverABodyWithNoRows(t *testing.T) { foot := placeTailed(page.foot(a)) want := wayOut if page.what == "tasks" { - want += " home" + want += " close" } for _, clause := range page.bad { if strings.Contains(foot, clause) { diff --git a/internal/tui3/subharness.go b/internal/tui3/subharness.go index 670289009..206b79bbd 100644 --- a/internal/tui3/subharness.go +++ b/internal/tui3/subharness.go @@ -151,9 +151,11 @@ const ( subRunVerbs = "↑↓ · enter runs it · esc back" // subOfferVerbs is the hint while the cursor is on the answer row of a card // CHAT raised. It names the keys that row actually draws and not one more: - // The chips are walked with arrows and taken with Enter. Zero declines; - // Escape hides the card without answering. - subOfferVerbs = "←→ · enter takes it · 0 no · esc back" + // the chips are walked with ←/→ and taken with enter, and both `0` and `esc` + // are the no — `esc` because it is the dismiss key everywhere in a + // conversation, `0` because it is the decline every card on this surface + // answers to (standing.go's [session.StandingNoKey]). + subOfferVerbs = "←→ · enter takes it · 0 or esc, no" // subEditHint is the placeholder in the box while one field is being typed // into. It takes the filter's place — one box under the overlay, answering // one question at a time (the connections panel's key box does the same). @@ -856,11 +858,6 @@ func (p *subPage) drawCard(a *app, width, n int, hover int) []string { // onto it, so the refusal names that machine before this code asks the local // seam. A capability that cannot work is absent rather than broken. func (a *app) openSubharness(name string) { - if a.subPage.card.asked() { - a.subPage.open = true - a.touch() - return - } if a.hosted() { a.note(a.remoteProfileWord("subharnesses")) return @@ -1001,11 +998,11 @@ func (a *app) withdrawSubharnessProposal(id uint64, name string) { // turn is technically working — the propose_subharness call is parked inside its // batch — and what is true about it that a person can act on is that it is // waiting for them (render.go's [app.stateWord]). -func (a *app) awaitingSubharness() bool { return a.subPage.card.asked() } +func (a *app) awaitingSubharness() bool { return a.subPage.open && a.subPage.card.asked() } // answerSubharnessOffer is the one place [subharnessOfferAgent.ResolveSubharness] -// is called from: the two chips, -// `0`, and the run row's own enter. Escape only defers the card. +// is called from, and every road off this card ends here: the two chips, `esc`, +// `0`, and the run row's own enter. // // THE CARD CLOSES EITHER WAY, because both answers are answers — a decline is // not an abandonment, and the engine is told so rather than left to time out on @@ -1121,11 +1118,12 @@ func (a *app) subCardKey(msg tea.KeyPressMsg) tea.Cmd { var cmd tea.Cmd switch msg.String() { case "esc": - // Back defers the offer without answering it. /subharness restores - // this same card until the engine answers or withdraws it. + // ESC ON A CARD CHAT RAISED IS THE NO, AND NOT AN ABANDONMENT. There is + // a turn on the other end of this question; walking away from it would + // leave that turn parked for a quarter of an hour on an answer the person + // has already given by pressing the dismiss key. if c.asked() { - a.subPage.open = false - a.note("subharness waiting · /subharness to return") + cmd = a.answerSubharnessOffer(false) break } // BACK OUT BY ONE. A card opened off the list goes back to the list; one diff --git a/internal/tui3/subharness_test.go b/internal/tui3/subharness_test.go index 6b57b5cbe..970d1f849 100644 --- a/internal/tui3/subharness_test.go +++ b/internal/tui3/subharness_test.go @@ -669,8 +669,8 @@ func TestTheStatusLineSaysYourCallWhileTheOfferStands(t *testing.T) { t.Fatalf("the status line read %q while a card was up", word) } drive(t, a, key("esc")) - if !a.awaitingSubharness() { - t.Fatal("a deferred offer stopped being waited on") + if a.awaitingSubharness() { + t.Fatal("an answered offer is still being waited on") } } @@ -701,15 +701,16 @@ func TestEnterOnTheAnswersRunsWhatChatOffered(t *testing.T) { } } -// Escape puts the offer aside without answering the turn waiting for it. -func TestEscOnAnOfferDefersWithoutAnswering(t *testing.T) { +// ESC IS A NO AND NOT A WAY OUT. There is a turn waiting on this question, so +// the key that dismisses every other overlay answers this one. +func TestEscOnAnOfferAnswersNoRatherThanWalkingAway(t *testing.T) { a, agent := answeredApp(t) drive(t, a, key("esc")) - if len(agent.answers) != 0 { + if len(agent.answers) != 1 || agent.answers[0].run { t.Fatalf("esc on the card answered %+v", agent.answers) } if a.subPage.open { - t.Fatal("the card stayed up after it was deferred") + t.Fatal("the card stayed up after it was declined") } } diff --git a/internal/tui3/surface_test.go b/internal/tui3/surface_test.go index 0c2f71dc2..57ebbf152 100644 --- a/internal/tui3/surface_test.go +++ b/internal/tui3/surface_test.go @@ -285,7 +285,7 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { // THE EXIT IS TAUGHT AFTER THE ENTRANCE (welcome.go's [app.dismissWelcome]): // the greeting's frame carries no line about leaving, and the line lands the // moment the conversation begins. - if strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("the greeting teaches the way out before the way in:\n%s", plain(frame(a))) } drive(t, a, key("h")) @@ -295,15 +295,14 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { if !strings.Contains(plain(frame(a)), welcomeStarterKeysWord) { t.Fatalf("typing moved the first conversation's composer out from under the person:\n%s", plain(frame(a))) } - if strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("a keystroke dismissed the first conversation's greeting:\n%s", plain(frame(a))) } // THE HINT LANDS WHEN THE CONVERSATION BEGINS — the send, not the typing // (welcome.go's [app.spendWelcome]). IT HAS TO BE TRUE ON THAT FRAME, where - // a turn has just started: esc goes back, and ctrl+c interrupts while - // working or quits at rest. + // a turn has just started: esc interrupts and ctrl+c quits at rest. drive(t, a, key("enter")) - if !strings.Contains(plain(frame(a)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(a)), "esc interrupts · ctrl+c quits") { t.Fatalf("the hint has to name both doors truthfully, and it lands at the send:\n%s", plain(frame(a))) } // CASE TWO, A PROFILE THAT HAS MET THE SETUP: the marker is in the test's @@ -316,18 +315,18 @@ func TestTheOpeningHintNamesBothDoors(t *testing.T) { b := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m"}, Workspace: "/tmp/lab", ProfileDir: metSetup}) b.width, b.height = 90, 30 b.touch() - if strings.Contains(plain(frame(b)), "esc back · ctrl+c interrupts or quits") { + if strings.Contains(plain(frame(b)), "esc interrupts · ctrl+c quits") { t.Fatalf("the greeting taught the way out before the way in on a profile that has met the setup:\n%s", plain(frame(b))) } drive(t, b, key("h")) - if !strings.Contains(plain(frame(b)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(b)), "esc interrupts · ctrl+c quits") { t.Fatalf("a keystroke on a profile that has met the setup did not land the hint:\n%s", plain(frame(b))) } // And a session that opens on a transcript gets it on its first frame. resumed := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m", past: []session.DisplayEntry{{Role: "user", Text: "hi"}}}, Workspace: "/tmp/lab", Resumed: true, ProfileDir: t.TempDir()}) resumed.width, resumed.height = 90, 30 - if !strings.Contains(plain(frame(resumed)), "esc back · ctrl+c interrupts or quits") { + if !strings.Contains(plain(frame(resumed)), "esc interrupts · ctrl+c quits") { t.Fatalf("a resumed session lost its opening line:\n%s", plain(frame(resumed))) } if !strings.Contains(helpText("", chordSpelling{}), "alt+enter") { @@ -691,7 +690,7 @@ func TestSlashOpensTheCommandListFiltersItAndRunsIt(t *testing.T) { t.Fatalf("a bare slash has to offer everything (%d hits)", len(a.menu.hits)) } drawn := plain(strings.Join(a.overlayRows(a.width, a.overlayHeight()), "\n")) - if !strings.Contains(drawn, "/ask") || !strings.Contains(drawn, "ask here on home") { + if !strings.Contains(drawn, "/attach") || !strings.Contains(drawn, "attach") { t.Fatalf("the list draws a name and a line about it:\n%s", drawn) } @@ -874,7 +873,7 @@ func TestHelpPrintsTheAliasesFromTheSameTable(t *testing.T) { "also /exit /q", "also /?", "/rewind", - "go back to an earlier point", + "go back to an earlier point · esc esc takes back the last", "also /undo /back", } { if !strings.Contains(text, want) { diff --git a/internal/tui3/switch_test.go b/internal/tui3/switch_test.go index e8255ded6..7dd6f6087 100644 --- a/internal/tui3/switch_test.go +++ b/internal/tui3/switch_test.go @@ -190,7 +190,7 @@ func TestASwitchKeepsParkedMessagesStructuredBesideTheDraft(t *testing.T) { t.Fatalf("the structured queue came back as %+v", a.parks) } drawn := plain(strings.Join(a.parkedRows(120), "\n")) - for _, want := range []string{"and check the tests", "shot.png", "then push", "wait for this answer", "ctrl+c stops and drops"} { + for _, want := range []string{"and check the tests", "shot.png", "then push", "wait for this answer", "esc stops and drops"} { if !strings.Contains(drawn, want) { t.Fatalf("the waiting block is missing %q:\n%s", want, drawn) } diff --git a/internal/tui3/tabreopen_recovery_test.go b/internal/tui3/tabreopen_recovery_test.go index 07c291f1f..aaa1db1e7 100644 --- a/internal/tui3/tabreopen_recovery_test.go +++ b/internal/tui3/tabreopen_recovery_test.go @@ -52,7 +52,7 @@ func TestReopenSharedRemoteTabKeepsRefusalForRetryFromHome(t *testing.T) { } } -func TestEscapeStaysHomeAndReopenRestoresTheLastClosedTab(t *testing.T) { +func TestReopenSkipsTheLastClosedTabAfterEscapeAlreadyReturnedToIt(t *testing.T) { a := reopenApp(t) var closed []string for i := 0; i < 3; i++ { @@ -63,11 +63,11 @@ func TestEscapeStaysHomeAndReopenRestoresTheLastClosedTab(t *testing.T) { t.Fatal("closing the final tab did not go Home") } drive(t, a, key("esc")) - if !a.at(pageHome) || a.file != closed[2] { - t.Fatal("Escape left Home or changed the underlying conversation") + if a.pageShowing() || a.file != closed[2] { + t.Fatal("Escape did not return to the same underlying conversation") } drive(t, a, reopenPress()) - if a.at(pageHome) || a.file != closed[2] || a.tabShut[a.convKey(closed[2])] { - t.Fatal("reopen did not restore the last closed tab from Home") + if a.file != closed[1] || a.tabShut[a.convKey(closed[2])] { + t.Fatal("reopen spent a key on the already-visible tab instead of the prior closure") } } diff --git a/internal/tui3/taskphone.go b/internal/tui3/taskphone.go index e57d74caa..88c7ca8cc 100644 --- a/internal/tui3/taskphone.go +++ b/internal/tui3/taskphone.go @@ -177,12 +177,12 @@ func (a *app) stripPhonePress(width int) (tea.Cmd, bool) { // two-cell lead ([tasksBareLead]) already sits in front of. const taskSheetPhoneIndent = 2 -// taskSheetBar is the roster page's foot at [tierPhone]: an `esc home` band a thumb +// taskSheetBar is the roster page's foot at [tierPhone]: an `esc close` band a thumb // leaves by, in place of the key legend a keyboard reads ([tasksPlace.hint]). // It is the record card's own bar shape ([phoneBar]) — one target here, because // filtering the page is done by typing and there is no toggle to give a band to. func (a *app) taskSheetBar(width int) (string, []hudSpan) { - back := homeDoorWord + back := mapCloseWords if a.taskSheetFiltering() { back = tasksClearFilterWord } @@ -190,7 +190,7 @@ func (a *app) taskSheetBar(width int) (string, []hudSpan) { } // taskSheetBarPress follows the same back action as Escape: clear a filter first, -// then return to Home. +// then return to the conversation. func (a *app) taskSheetBarPress(x int) tea.Cmd { width, _ := a.size() _, spans := a.taskSheetBar(width) diff --git a/internal/tui3/taskphone_test.go b/internal/tui3/taskphone_test.go index 990ff3e73..7fdc242d3 100644 --- a/internal/tui3/taskphone_test.go +++ b/internal/tui3/taskphone_test.go @@ -214,7 +214,7 @@ func TestThePhoneRosterFootIsABackBarToTheConversation(t *testing.T) { lines := taskSheetLines(a) foot := lines[len(lines)-1] - if !strings.Contains(foot, homeDoorWord) { + if !strings.Contains(foot, mapCloseWords) { t.Fatalf("the phone foot is not a back bar:\n%q", foot) } // And the key legend a keyboard reads is gone from it. @@ -228,8 +228,8 @@ func TestThePhoneRosterFootIsABackBarToTheConversation(t *testing.T) { } drive(t, a, tea.MouseClickMsg{X: 2, Y: y, Button: tea.MouseLeft}) drive(t, a, tea.MouseReleaseMsg{X: 2, Y: y, Button: tea.MouseLeft}) - if !a.at(pageHome) { - t.Fatal("tapping esc home did not return to Home") + if !a.at(pageNone) { + t.Fatal("tapping esc close did not return to the conversation") } } diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index b07ec1b43..15db1a8c7 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -1837,14 +1837,14 @@ func TestTheSameNoteTwiceRunningIsOneNote(t *testing.T) { } } -func TestCtrlCInterruptsThenCloses(t *testing.T) { +func TestEscInterruptsAndCtrlCCloses(t *testing.T) { agent := &fakeAgent{model: "m", turns: [][]session.Event{{ text(session.EventTextDelta, "thinking about it"), }}} a := newTestApp(agent) typeLine(t, a, "long one") - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if agent.stops != 1 { t.Fatalf("ctrl+c did not interrupt (%d)", agent.stops) } diff --git a/internal/tui3/watching.go b/internal/tui3/watching.go index 3d78b7219..f20cb0746 100644 --- a/internal/tui3/watching.go +++ b/internal/tui3/watching.go @@ -90,7 +90,7 @@ func (a *app) watching() bool { // weaker claim of the two and the one that stays true either way. // // IT DOES NOT ADVERTISE THE DOOR HOME, and that is not an omission. The foot of -// the frame already says `esc back` in the hint slot ([homeDoorWord]), +// the frame already says `space space home` in the hint slot ([homeDoorWord]), // the gesture still works from here, and a second copy of it in this line would // be the surface teaching one door in two places. func (a *app) watchWord() string { @@ -137,16 +137,48 @@ func (a *app) watchBar(width int) []string { // send keys, which become the take-back, and a character typed into a box that // is not on the frame. // -// Escape falls through to the shared back navigation; characters cannot edit -// the hidden composer or trigger navigation in a watching window. +// THE DOOR HOME IS COUNTED HERE RATHER THAN LET THROUGH, and that is worth the +// four lines it costs. [app.homeGesture] reads two consecutive spaces out of the +// BOX, and this register has no box on the frame — so letting the space fall +// through while swallowing the letters turned a typed sentence into a run of +// spaces and opened home on the first word with two in it. Driven over a real +// connection, `this should be swallowed` walked straight out of the +// conversation. The gesture is the same gesture; it is simply counted where the +// keys actually are, and any other key ends the run exactly as a letter in the +// box would. func (a *app) watchKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { switch msg.String() { case "enter", standMarkKey: + a.watchSpaces = 0 return a.takeKeyboard(), true case "alt+enter", "ctrl+j": + // A newline into a box nobody can see is nothing at all. + a.watchSpaces = 0 return nil, true } - return nil, msg.Key().Text != "" + text := msg.Key().Text + if text == "" { + // Not a character: the arrows, the scroll keys, copy mode, the places. + // They are not this register's business and they end the run. + a.watchSpaces = 0 + return nil, false + } + if text != " " { + a.watchSpaces = 0 + return nil, true + } + // A SPACE ONLY COUNTS OVER AN EMPTY DRAFT, which is the condition the + // gesture has always had: a person with words already in the box meant a + // space in their sentence, and this window is still holding those words. + if !a.input.empty() { + return nil, true + } + a.watchSpaces++ + if a.watchSpaces >= 2 && a.homeDoorOpen() { + a.watchSpaces = 0 + return a.openHome(), true + } + return nil, true } // takeKeyboard asks the far machine for the keyboard back. diff --git a/internal/tui3/watching_test.go b/internal/tui3/watching_test.go index cdde3f618..ac6eb0bd2 100644 --- a/internal/tui3/watching_test.go +++ b/internal/tui3/watching_test.go @@ -159,14 +159,13 @@ func TestAWatcherCanStillWalkAwayToHome(t *testing.T) { t.Fatal("the spaces inside a typed sentence opened home") } - // Spaces are swallowed too; Escape is the shared back key. + // And two CONSECUTIVE spaces are still the door, counted where the keys are. if !a.homeDoorOpen() { t.Skip("home is not reachable from this test surface") } drive(t, a, key(" "), key(" ")) - a.key(key("esc")) if !a.at(pageHome) { - t.Fatal("Escape at a watcher did not open home") + t.Fatal("two spaces at a watcher did not open home") } } diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index ee998f950..0cf5206e9 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -199,7 +199,7 @@ func (a *app) openWelcome() { // dismissWelcome puts the unit away for good, and says the two keys that leave. // -// THE EXIT IS TAUGHT AFTER THE ENTRANCE. `esc back · ctrl+c interrupts or quits` +// THE EXIT IS TAUGHT AFTER THE ENTRANCE. `esc interrupts · ctrl+c quits` // used to be the first line of every session, drawn above a greeting whose whole // job was to get somebody to type their first sentence — a way out, offered // before the way in. So while the unit is up the transcript carries nothing, diff --git a/internal/tui3/wiring_test.go b/internal/tui3/wiring_test.go index 3aaa93e91..62f6c48c4 100644 --- a/internal/tui3/wiring_test.go +++ b/internal/tui3/wiring_test.go @@ -594,7 +594,7 @@ func TestAnInterruptDropsWhatWasQueued(t *testing.T) { } _ = agent - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.follows) != 0 { t.Fatal("the interrupt kept the queue the session just dropped") } @@ -661,7 +661,7 @@ func TestFollowUpsDrainInOrderBeforeTheParkedMessage(t *testing.T) { // M10: Esc drops both session-owned queues, closes every follow-up stream, and // drops the surface-owned parked queue before the stopped stream ends. -func TestCtrlCClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { +func TestEscClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { agent, a := wired([]session.Event{text(session.EventTextDelta, "working")}) typeLine(t, a, "the first turn") settleAsk(a) @@ -672,17 +672,17 @@ func TestCtrlCClosesQueuedStreamsAndDropsTheParkedTurn(t *testing.T) { parkLine(t, a, "the parked message") firstTurn := a.turn - drive(t, a, key("ctrl+c")) + drive(t, a, key("esc")) if len(a.follows) != 0 { - t.Fatalf("Ctrl+C left %d follow-ups on the surface", len(a.follows)) + t.Fatalf("Esc left %d follow-ups on the surface", len(a.follows)) } for index, stream := range agent.followStreams { if _, open := <-stream; open { - t.Fatalf("follow-up stream %d remained open after Ctrl+C", index) + t.Fatalf("follow-up stream %d remained open after Esc", index) } } if len(agent.sent) != 1 || len(a.parks) != 0 { - t.Fatalf("Ctrl+C did not drop the parked message: sent=%q parks=%+v", agent.sent, a.parks) + t.Fatalf("Esc did not drop the parked message: sent=%q parks=%+v", agent.sent, a.parks) } agent.finish()