From 9d83eaca1c280ea437f3e05bb4bd649994d473de Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:24:30 -0400 Subject: [PATCH 1/6] chat: keep space space home beside a conversation's tip, and a tip that says it The earned tip took the whole keys row, so from a conversation's first exchange onward the door home was named only on task pages. It now rides after the tip and outlasts it on a narrow frame. A twenty-third tip, 'space space takes you back to home', is said in a conversation only and retires on the gesture itself. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/hints-and-tips.md | 14 ++++-- internal/manual/chat/home.md | 6 +++ internal/manual/chat/keys.md | 3 +- internal/tui3/chattip_test.go | 70 +++++++++++++++++++++++++- internal/tui3/hometip_test.go | 9 ++-- internal/tui3/input.go | 1 + internal/tui3/notice.go | 29 +++++++++-- internal/tui3/notice_test.go | 9 ++++ internal/tui3/placekeys.go | 2 + internal/tui3/render.go | 18 ++++++- internal/tui3/steer.go | 6 +++ 11 files changed, 154 insertions(+), 13 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 3b2620fa0..155f0725a 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -16,7 +16,11 @@ rest state — the line a newcomer reads when nothing is happening — and every keys of its own outranks it: a running turn, a list, a panel, a room, and a box with so much as one letter in it. Empty the box and it is back. There is no clock over it and no cross on it: the event that makes a tip true puts it there, and it stays until something -truer takes the row or you use what it teaches. +truer takes the row or you use what it teaches. **The way home stays beside it**: the row +reads, say, `/files finds files codeaf wrote for you · space space home`, and on a narrow +window the tip gives way first and `space space home` is kept. (Until 2026-09-24 the tip +took the whole row, so after a conversation's first exchange the door home was not named +anywhere on it.) **Home says its tips differently, and the two are not the same row.** Home's is the dim line **directly above the rule** over its box, right-aligned, led by a bulb and closed by a @@ -25,7 +29,7 @@ it until you leave home and come back. A conversation is a screen you sit in and screen you pass through, so the tip worth saying differs: a conversation gets the most urgent thing that is true right now, and home gets everything in turn. -Both rows draw from **one list** of twenty-two tips (below), and using a gesture on +Both rows draw from **one list** of twenty-three tips (below), and using a gesture on either retires it on both. **A conversation's tip has changed places twice.** It was the keys row's lowest rung until @@ -99,7 +103,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-two, one list for both boxes. Each one says the moment it first appears +There are twenty-three, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -159,6 +163,10 @@ build if the two disagree), so a tip you saw is on it word for word. **Moving around** - `ctrl+t starts a fresh chat in this project` — retired when the new-chat page opens. +- `space space takes you back to home` — after the first exchange, in a conversation only + (never on home itself). Retired the first time two spaces in an empty box open home, + from a conversation or from a place; reaching home by `/home` or the tab does not retire + it. While it is the tip, the row does not add `space space home` after it a second time. **Memory, accounts and the rest** diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3ed79f62a..7a0fd5d05 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1475,6 +1475,12 @@ and not decoration. It also goes while a turn is running, where the same row has 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.) +**It stays when a tip is showing.** After the first exchange the keys row often carries a +tip instead of the controls, and the door is kept after it — +`/files finds files codeaf wrote for you · space space home`. Until 2026-09-24 the tip took +the whole row and the door went with it. One of the tips is about the door itself: +`space space takes you back to home`. + **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. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 7213acc59..1806bb286 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2203,7 +2203,8 @@ 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. +`space space home` opens home; that clause vanishes as soon as you type. When a tip has +taken the row it still ends with `· space space home`. **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 diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index 0a9794a71..11ea78e48 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -25,10 +25,13 @@ import ( // reports from the same day: enter on `/attach` in the list opens the browser // at once, and the search place finds conversations by name when memory is off. -// chatTipLab is a conversation over a clock the test turns by hand. +// chatTipLab is a conversation over a clock the test turns by hand, with a +// door that can open a conversation, so home is somewhere to go back to +// ([app.homeDoorOpen]). func chatTipLab(t *testing.T) (*app, func(time.Duration)) { t.Helper() a, _ := sheetApp(t) + a.open = func(string, string) (Conversation, error) { return Conversation{}, nil } now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) a.clock = func() time.Time { return now } return a, func(d time.Duration) { now = now.Add(d) } @@ -105,6 +108,71 @@ func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { } } +// THE DOOR HOME STAYS BESIDE THE TIP (2026-09-24). The tip used to take the +// whole keys row, so from a conversation's first exchange onward +// `space space home` was named nowhere on it. It rides after the tip now, and a +// narrow frame gives up the tip before the door. +func TestTheDoorHomeStaysBesideTheConversationsTip(t *testing.T) { + a, _ := chatTipLab(t) + makeDeliverable(t, a) + want := deliverTip + hintSegment + homeDoorWord + if got := plain(a.footHint(a.width)); got != want { + t.Fatalf("the keys row reads %q, want %q", got, want) + } + if y, rows := tipRowOf(a, want); y < 0 { + t.Fatalf("the frame does not carry the tip and the door together:\n%s", strings.Join(rows, "\n")) + } + if !a.homeDoor.holds(a.homeDoor.from) { + t.Fatal("the door beside the tip was drawn but not recorded for a click") + } + if got := a.hintShorter(want); got != homeDoorWord { + t.Fatalf("a narrow frame kept %q, want the door alone", got) + } + if got := a.hintShorter(homeDoorWord); got != "" { + t.Fatalf("the door alone shortened to %q", got) + } + + // A letter in the box takes the tip and the door together. + drive(t, a, key("x")) + if got := plain(a.footHint(a.width)); strings.Contains(got, homeDoorWord) { + t.Fatalf("the door is still named over a box with a letter in it: %q", got) + } +} + +// THE TIP ABOUT THE DOOR (2026-09-24) is said in a conversation after its first +// exchange and never on home, and while it stands the row does not name the +// door a second time after it. +func TestTheWayHomeTipIsSaidAwayFromHomeOnly(t *testing.T) { + var row notice + for _, n := range notices { + if n.id == "home-by-two-spaces" { + row = n + } + } + if row.id == "" { + t.Fatal("the way-home tip is not on the table") + } + if row.text != "space space takes you back to home" || row.retire != eventHomeGesture { + t.Fatalf("the way-home tip reads %q and retires on %q", row.text, row.retire) + } + a, _ := chatTipLab(t) + if row.armed(a) { + t.Fatal("the way-home tip is armed before the first exchange") + } + a.turn = 1 + if !row.armed(a) { + t.Fatal("the way-home tip is not armed after the first exchange") + } + runCmd(a.showPage(pageHome)) + if row.armed(a) { + t.Fatal("the way-home tip is armed on home itself") + } + a.leavePlace() + if got := a.tipWithHomeDoor(row.text); got != row.text { + t.Fatalf("the way-home tip named the door twice: %q", got) + } +} + // Silencing hints silences the conversation's row along with home's. func TestDisableHintsSilencesTheConversationRow(t *testing.T) { a, _ := chatTipLab(t) diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 27272448f..b6279e1b3 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -246,10 +246,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { } // The cut was thirty, /project made it thirty-one, and three reads of the whole -// list by the owner took it to twenty-two. There is ONE set: every hint draws +// list by the owner took it to twenty-two, and the way home by two spaces made +// it twenty-three on 2026-09-24. There is ONE set: every hint draws // on both boxes, a news row on neither, and a row filed under home's slot does // not build. -func TestTheTableIsTwentyTwoHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +func TestTheTableIsTwentyThreeHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -263,8 +264,8 @@ func TestTheTableIsTwentyTwoHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 22 { - t.Fatalf("the table holds %d hints, want 22 — the cut is deliberate, and the manual page counts them", hints) + if hints != 23 { + t.Fatalf("the table holds %d hints, want 23 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/input.go b/internal/tui3/input.go index ce7d70a19..2d31de335 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1313,6 +1313,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // ago, through the line below. if a.homeGesture(msg) { a.input.reset() + a.noticeEvent(eventHomeGesture) return tea.Batch(a.edited(), a.openHome()) } if text := msg.Key().Text; text != "" { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index e538a5613..0336950cb 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -188,6 +188,12 @@ const ( // eventAutonomyAsked is /autonomy reaching its command, bare or with a // rule (autonomysheet.go). eventAutonomyAsked = "autonomy-asked" + // eventHomeGesture is two spaces over an empty box opening home, from a + // conversation or from a place (input.go, placekeys.go). It is the + // gesture alone and not every way home: `/home` and a click on the tab + // are doors a person already knows, and the tip is about the one they + // cannot see. + eventHomeGesture = "home-gesture" ) // noticeEvents is every event there is, in one list, so the table check can @@ -201,7 +207,7 @@ var noticeEvents = []string{ eventFolderPicked, eventProjectSet, eventModelListOpened, eventCrewShown, eventBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSearchOpened, eventSubharnessOpened, - eventConnectOpened, eventAutonomyAsked, + eventConnectOpened, eventAutonomyAsked, eventHomeGesture, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -298,6 +304,10 @@ var ( // while home is in front and stands down the moment it is not, so the one // list can hold a sentence that would be a lie over a conversation's box. onHome = func(a *app) bool { return a.at(pageHome) } + // awayFromHome is a conversation that has had an exchange and is in + // front: the other half of [onHome], for a tip about the way back, which + // would be a lie over home's own box. + awayFromHome = func(a *app) bool { return a.turn >= 1 && !a.at(pageHome) } ) // notices is the table, and ITS ORDER IS THE ORDER THE ROWS COME ROUND IN on @@ -306,11 +316,12 @@ var ( // and the page say the same words — and notice_test.go holds the page to every // line here, so the table cannot say a thing the manual does not. // -// TWENTY-TWO ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on +// TWENTY-THREE ROWS, AND EVERY CUT WAS DELIBERATE. A survey of the surface on // 2026-09-21 turned up forty-eight lines worth saying; thirty of those shipped, // /project made thirty-one when it became a command of its own on 2026-09-22, // and three reads of the whole list by the owner that same day took it to -// twenty-two. `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off +// twenty-two; the way home by two spaces made it twenty-three on 2026-09-24, +// at the owner's word. `alt+3`, `alt+1`–`alt+7`, `/search` and `/subharness` came off // as rows the foot or the tab bar already teaches; the two lines about a // running answer became one; `/ask`'s came off ahead of the door it taught; // `/folder`'s came off because it was not true and /attach's line now covers @@ -492,6 +503,18 @@ var notices = []notice{ text: "ctrl+t starts a fresh chat in this project", retire: eventChatStarted, }, + { + // THE WAY BACK, AT THE OWNER'S WORD ON 2026-09-24. The foot names the + // door as `space space home` beside every tip ([app.footHint]), but a + // label of three words reads as chrome after the first day, and this + // row says what it is in a sentence. It is never armed on home, where + // there is nowhere to go back to, and it retires on the gesture itself + // rather than on reaching home by `/home` or the tab. + id: "home-by-two-spaces", slot: slotHint, + armed: awayFromHome, + text: "space space takes you back to home", + retire: eventHomeGesture, + }, // ── memory, accounts and the rest ─────────────────────────────────────── { id: "remember-one-thing", slot: slotHint, diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 6d31d6925..87754532e 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -688,6 +688,15 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, + eventHomeGesture: func(t *testing.T, a *app) { + // A door that can open a conversation is what opens home at all + // ([app.homeDoorOpen]); the bare test app has none. + a.open = func(string, string) (Conversation, error) { return Conversation{}, nil } + drive(t, a, key(" "), key(" ")) + if !a.at(pageHome) { + t.Fatal("two spaces did not open home") + } + }, } for _, name := range noticeEvents { if name == eventBoot { diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 7aac05bea..8cb4f4cac 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -446,6 +446,7 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { } if a.placeSpaceArmed { a.placeSpaceArmed = false + a.noticeEvent(eventHomeGesture) return a.openHome(), true } a.placeSpaceArmed = true @@ -455,6 +456,7 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } box.reset() + a.noticeEvent(eventHomeGesture) return a.openHome(), true } diff --git a/internal/tui3/render.go b/internal/tui3/render.go index c64b134dd..d07dddff5 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3737,12 +3737,28 @@ func (a *app) footHint(width int) string { // the rule with a clock and a cross, and the owner put it back here. Home's // row keeps that newer shape; the two boxes are read differently and are // allowed to differ (notice.go's [noticeBoard.pick]). + // + // AND THE DOOR HOME STAYS BESIDE IT, since 2026-09-24 at the owner's word. + // The tip used to take the whole row, so from a conversation's first + // exchange onward `space space home` was gone — drawn on a task page, + // where no tip stands, and nowhere a person actually talks. A tip that is + // itself about the door does not say it twice. if tip := a.noticeHint(); tip != "" { - return tip + return a.tipWithHomeDoor(tip) } return a.idleHint() } +// tipWithHomeDoor is the earned tip followed by the door home, when the door is +// showing and the tip does not already teach it. [app.hintShorter] gives the +// tip up first on a narrow frame, because the door is the one that stays true. +func (a *app) tipWithHomeDoor(tip string) string { + if !a.homeDoorShowing() || strings.Contains(tip, "space space") { + return tip + } + return tip + hintSegment + homeDoorWord +} + // idleHint keeps the shared controls in home's order, followed by the way home. // Each control is named only where the session can take its key. func (a *app) idleHint() string { diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 5d2c48417..4542f3200 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -487,6 +487,12 @@ func (a *app) runHint() string { // Idle controls yield from the left to commands and home; running hints keep // their existing priority from the left. Every step removes at least one clause. func (a *app) hintShorter(slot string) string { + // A TIP WITH THE DOOR BESIDE IT gives up the tip and keeps the door + // (render.go's [app.tipWithHomeDoor]). A tip alone yields nothing, as it + // always did: half a sentence is not a tip. + if slot != homeDoorWord && strings.HasSuffix(slot, hintSegment+homeDoorWord) && slot == a.tipWithHomeDoor(a.noticeHint()) { + return homeDoorWord + } idle := a.idleHint() if slot != "" && (slot == idle || strings.HasSuffix(idle, hintSegment+slot)) { parts := strings.Split(slot, hintSegment) From 8b7cbd84eedf817c51dfa8ea2fb77a421c7d1c73 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:26:53 -0400 Subject: [PATCH 2/6] chat: the way-home tip is the first a conversation earns, and only where the door opens Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/hints-and-tips.md | 9 ++++--- internal/tui3/notice.go | 36 ++++++++++++++------------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 155f0725a..f223eee08 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -124,6 +124,11 @@ build if the two disagree), so a tip you saw is on it word for word. a conversation. Retired when you run `/resume`. - `/standing turns a message into a rule work must follow` — once this directory has three or more earlier conversations. Retired when a standing order is made or the standing page opened. +- `space space takes you back to home` — after the first exchange, in a conversation only + (never on home itself). Retired the first time two spaces in an empty box open home, + from a conversation or from a place; reaching home by `/home` or the tab does not retire + it. While it is the tip, the row does not add `space space home` after it a second time. It is + the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. - `ctrl+enter makes your message a rule instead of a request` — retired when a standing @@ -163,10 +168,6 @@ build if the two disagree), so a tip you saw is on it word for word. **Moving around** - `ctrl+t starts a fresh chat in this project` — retired when the new-chat page opens. -- `space space takes you back to home` — after the first exchange, in a conversation only - (never on home itself). Retired the first time two spaces in an empty box open home, - from a conversation or from a place; reaching home by `/home` or the tab does not retire - it. While it is the tip, the row does not add `space space home` after it a second time. **Memory, accounts and the rest** diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 0336950cb..97caad323 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -304,10 +304,11 @@ var ( // while home is in front and stands down the moment it is not, so the one // list can hold a sentence that would be a lie over a conversation's box. onHome = func(a *app) bool { return a.at(pageHome) } - // awayFromHome is a conversation that has had an exchange and is in - // front: the other half of [onHome], for a tip about the way back, which - // would be a lie over home's own box. - awayFromHome = func(a *app) bool { return a.turn >= 1 && !a.at(pageHome) } + // awayFromHome is a conversation that has had an exchange, with the door + // home open from where it stands ([app.homeDoorOpen]): the other half of + // [onHome], for a tip about the way back, which would be a lie over home's + // own box and over a door that cannot open anything. + awayFromHome = func(a *app) bool { return a.turn >= 1 && a.homeDoorOpen() } ) // notices is the table, and ITS ORDER IS THE ORDER THE ROWS COME ROUND IN on @@ -399,6 +400,21 @@ var notices = []notice{ // until 2026-09-22 and came off ahead of the door it taught: /ask is on // its way out, and a tip is a thing to teach somebody who will still have // it tomorrow. The command itself is untouched. + { + // THE WAY BACK, AT THE OWNER'S WORD ON 2026-09-24. The foot names the + // door as `space space home` beside every tip ([app.footHint]), but a + // label of three words reads as chrome after the first day, and this + // row says what it is in a sentence. It is never armed on home, where + // there is nowhere to go back to, and it retires on the gesture itself + // rather than on reaching home by `/home` or the tab. IT RANKS FIRST of + // the rows a conversation earns by talking, because a person who does + // not know the way back out of a conversation has no use yet for any + // of the commands below it. + id: "home-by-two-spaces", slot: slotHint, + armed: awayFromHome, + text: "space space takes you back to home", + retire: eventHomeGesture, + }, { id: "task-in-chat", slot: slotHint, armed: spoken, @@ -503,18 +519,6 @@ var notices = []notice{ text: "ctrl+t starts a fresh chat in this project", retire: eventChatStarted, }, - { - // THE WAY BACK, AT THE OWNER'S WORD ON 2026-09-24. The foot names the - // door as `space space home` beside every tip ([app.footHint]), but a - // label of three words reads as chrome after the first day, and this - // row says what it is in a sentence. It is never armed on home, where - // there is nowhere to go back to, and it retires on the gesture itself - // rather than on reaching home by `/home` or the tab. - id: "home-by-two-spaces", slot: slotHint, - armed: awayFromHome, - text: "space space takes you back to home", - retire: eventHomeGesture, - }, // ── memory, accounts and the rest ─────────────────────────────────────── { id: "remember-one-thing", slot: slotHint, From aaa7c338bbd0b2a88fb9369f0ed433c68274a13c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:34:10 -0400 Subject: [PATCH 3/6] docs: change entry for #1487 Co-Authored-By: Claude Opus 5.5 --- .../1487-space-space-home-beside-the-tip.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/changes/unreleased/1487-space-space-home-beside-the-tip.md diff --git a/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md new file mode 100644 index 000000000..cffbe779b --- /dev/null +++ b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md @@ -0,0 +1,12 @@ +--- +kind: changed +title: a conversation keeps space space home beside its tip, and a new tip says what it does +pr: 1487 +surface: [chat, docs] +invalidates: + - "A conversation's earned tip took the whole keys row, so from the first exchange onward `space space home` was named only where no tip draws (a task page). The row now reads the tip followed by `· space space home`, and a narrow frame gives up the tip before the door." + - "The tip list was twenty-two rows. It is twenty-three: `space space takes you back to home`, the first row a conversation earns by talking (ahead of `/task`), armed only in a conversation whose door home opens and never on home, and retired by the two-space gesture itself (the new `home-gesture` event) — not by `/home` or the tab." +--- +The door home is the one key a person needs before any command a tip can teach, +and the tip that took the row was hiding it exactly when a conversation had +started to be used. From cda7af40fb49514ad4a1727903b5a44a59f39dba Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:00:32 -0400 Subject: [PATCH 4/6] chat: a conversation's tip covers the project on the keys row, with a cross, after 15 quiet seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tip no longer replaces the controls. It stands at the keys row's right end over `project: `, with home's bulb and a cross that puts it away until the conversation is left, and it waits for fifteen seconds with no key, click, scroll or paste. The project is back the moment it goes. The disable hints row now reads "disable💡 tips everywhere (requires restart)". Co-Authored-By: Claude Opus 5.5 --- .../1487-space-space-home-beside-the-tip.md | 11 +- internal/config/settings.go | 7 +- internal/manual/chat/hints-and-tips.md | 55 ++-- internal/manual/chat/home.md | 13 +- internal/manual/chat/keys.md | 4 +- internal/manual/chat/places.md | 6 +- internal/tui3/app.go | 39 +++ internal/tui3/chattip_test.go | 239 +++++++++++++----- internal/tui3/footswap.go | 19 ++ internal/tui3/hometip.go | 7 +- internal/tui3/hometip_test.go | 20 +- internal/tui3/hometiplayout_test.go | 7 + internal/tui3/notice.go | 156 +++++++++--- internal/tui3/notice_test.go | 15 +- internal/tui3/pages.go | 3 + internal/tui3/projectseam.go | 8 +- internal/tui3/render.go | 29 --- internal/tui3/settings.go | 6 +- internal/tui3/steer.go | 6 - internal/tui3/view.go | 5 +- 20 files changed, 448 insertions(+), 207 deletions(-) diff --git a/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md index cffbe779b..52b509b31 100644 --- a/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md +++ b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md @@ -1,12 +1,13 @@ --- kind: changed -title: a conversation keeps space space home beside its tip, and a new tip says what it does +title: a conversation's tip covers the project on the keys row, with a cross, after 15 quiet seconds pr: 1487 surface: [chat, docs] invalidates: - - "A conversation's earned tip took the whole keys row, so from the first exchange onward `space space home` was named only where no tip draws (a task page). The row now reads the tip followed by `· space space home`, and a narrow frame gives up the tip before the door." + - "A conversation's earned tip was the keys row's lowest rung and replaced the whole row, so from the first exchange onward the controls and `space space home` were gone while a tip stood. The controls keep their place now, and the tip stands at the keys row's right end, covering `project: ` while it is up, with home's bulb and a cross; the project is back when it goes." + - "A conversation's tip had no clock and no cross. It waits for 15 seconds with no key, click, scroll or paste and none since a turn ended, and its cross puts it away (giving the project back) until the conversation is left (home, a place, another conversation) and come back to. Home's row does not wait." + - "The Workspace tab's `disable hints` row explained itself in a sentence about which rows it silences. The line under it reads `disable💡 tips everywhere (requires restart)`, and `codeaf config` shows the same words. It still silences both rows and the what's-new lines." - "The tip list was twenty-two rows. It is twenty-three: `space space takes you back to home`, the first row a conversation earns by talking (ahead of `/task`), armed only in a conversation whose door home opens and never on home, and retired by the two-space gesture itself (the new `home-gesture` event) — not by `/home` or the tab." --- -The door home is the one key a person needs before any command a tip can teach, -and the tip that took the row was hiding it exactly when a conversation had -started to be used. +The controls are the keys that work right now and a tip is only a suggestion, +so the tip takes only the project's place, and only once somebody has stopped working. diff --git a/internal/config/settings.go b/internal/config/settings.go index 8f53d85d8..b043fa601 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2687,11 +2687,8 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyHints, Category: CategoryInterface, Kind: SettingBool, Label: "disable hints", - Hint: "on silences the one-line tips — the keys row's in a conversation and the " + - "row above the rule on home — and the what's-new line a new build may say " + - "with them. Off, the default, shows each tip until the key or command it " + - "names has been used once. A change lands at the end of the next turn.", - read: func() string { return formatBool(!HintsAt(dir)) }, + Hint: "disable💡 tips everywhere (requires restart)", + read: func() string { return formatBool(!HintsAt(dir)) }, write: func(raw string) error { disabled, err := parseBool(raw) if err != nil { diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index f223eee08..325669a27 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -6,36 +6,52 @@ The dim row under your message box — the last row of the frame — mostly name work right now: `esc interrupt` while an answer is coming, `y allow · n deny · a always` while codeaf is asking you something, `space space home` when there is a home to go to, `/ commands` when nothing else is true. 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 +sometimes carries a **tip** as well — one sentence naming a key or a command you have not used yet, and what it does, for example `esc esc or /rewind takes back an earlier message` or `/files finds files codeaf wrote for you`. It reads the way every hint on this surface does: the key or the command first, then what it does. -**In a conversation the tip is the keys row's lowest rung.** It takes that row from the -rest state — the line a newcomer reads when nothing is happening — and every state with -keys of its own outranks it: a running turn, a list, a panel, a room, and a box with so -much as one letter in it. Empty the box and it is back. There is no clock over it and no -cross on it: the event that makes a tip true puts it there, and it stays until something -truer takes the row or you use what it teaches. **The way home stays beside it**: the row -reads, say, `/files finds files codeaf wrote for you · space space home`, and on a narrow -window the tip gives way first and `space space home` is kept. (Until 2026-09-24 the tip -took the whole row, so after a conversation's first exchange the door home was not named -anywhere on it.) +**In a conversation the tip stands at the right end of the keys row, covering the +project** (`project: `) while it is up — led by a bulb (💡) and closed by a small +cross (`✕`), as home's is. The controls keep their place at the row's left: + +``` +─ glm-5.3-flash:auto · ◇ asks ────────────────────────────────────── $0.00 idle ─ + › + opt+e effort · / commands · space space home 💡 /files finds files codeaf wrote for you ✕ +``` + +When the tip goes, `project:` is back in its place. When the row is too narrow for the +whole sentence it is cut short with `…`, and where there is no room for a word of it the +project stays. + +**It waits for 15 seconds of quiet.** Any key, click, scroll or paste hides it and starts +the 15 seconds again, and so does an answer finishing — the tip never appears under an +answer you have just started reading or between two things you are typing. A running turn, +a list, a panel, a room, or a box with so much as one letter in it also keep it down. It +shows whether the task column is open or put away with `ctrl+g`. + +**The cross puts it away**, and gives the project back, until you leave the conversation — +for home, a place, or another conversation — and come back; it does not retire the tip, +which comes round again later. **Home says its tips differently, and the two are not the same row.** Home's is the dim line **directly above the rule** over its box, right-aligned, led by a bulb and closed by a small cross. It rotates on every visit and every two minutes at rest, and the cross blanks it until you leave home and come back. A conversation is a screen you sit in and home is a screen you pass through, so the tip worth saying differs: a conversation gets the most -urgent thing that is true right now, and home gets everything in turn. +urgent thing that is true right now, and home gets everything in turn. Home's row does not +wait for quiet; a conversation's waits 15 seconds. Both rows draw from **one list** of twenty-three tips (below), and using a gesture on either retires it on both. -**A conversation's tip has changed places twice.** It was the keys row's lowest rung until +**A conversation's tip has moved several times.** It was the keys row's lowest rung until 2026-09-22, moved up to a row of its own over the rule that day — with a quiet minute before it appeared, a two-minute rotation and a cross — and moved back to the keys row the -same day, which is where it is now. +same day, replacing the controls there. On 2026-09-24 it stopped replacing the controls, +spent part of that day above the rule again, and settled at the keys row's right end over +the project, with a cross and a 15-second wait, which is where it is now. On a Mac the row says `opt` where the table below says `alt`, exactly as the keys row does. @@ -127,8 +143,7 @@ build if the two disagree), so a tip you saw is on it word for word. - `space space takes you back to home` — after the first exchange, in a conversation only (never on home itself). Retired the first time two spaces in an empty box open home, from a conversation or from a place; reaching home by `/home` or the tab does not retire - it. While it is the tip, the row does not add `space space home` after it a second time. It is - the first tip a conversation says, ahead of `/task`. + it. It is the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. - `ctrl+enter makes your message a rule instead of a request` — retired when a standing @@ -219,9 +234,11 @@ for a chord that works only in a conversation and only over a making-shaped sent ## Turn off hints — stop showing tips, disable the hints, the disable hints row Open the settings panel with `/settings` (or `ctrl+,`), go to the **Workspace** tab, and flip -the **disable hints** row on. Enter or space toggles it; it is off by default, which means -the tips show. (Until 2026-09-22 it was a **hints** row on the Display tab, on by default.) -The change lands at the end of the next turn. On silences the tips — over a conversation's +the **disable hints** row on. The line under the row reads +`disable💡 tips everywhere (requires restart)`. Enter or space toggles it; it is off by +default, which means the tips show. (Until 2026-09-22 it was a **hints** row on the Display +tab, on by default.) The change lands at the end of the next turn in a conversation that is +already open, and from the start in one opened after — restarting codeaf is the sure way. On silences the tips — over a conversation's box and over home's alike — and the what's-new lines together; it does not touch the keys row's own words for a live state — `esc interrupt` and the rest are not hints and cannot be turned off. From the terminal, `codeaf config` shows the same row under the same name. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 7a0fd5d05..5bfe9d9d8 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1413,9 +1413,9 @@ home and every two minutes at rest, and each tip goes away for good the first ti what it names. It sits at the right, led by a bulb and closed by a small cross: clicking it means ENOUGH FOR NOW, and the row stays blank until you leave home and come back, when a different tip is there. The tip you put away keeps its whole allowance and comes round -again. **A conversation says its tips differently**: there the tip is the lowest rung of -the keys row at the very foot, with no bulb, no cross and no clock — it is simply there -whenever nothing else is happening. Both rows draw from the same one list. The whole list, +again. **A conversation says its tips differently**: its tip stands at the right end of +the keys row, over the project, with the bulb and the cross, and it appears only after +15 seconds in which you have not pressed, clicked, scrolled or pasted anything. Both rows draw from the same one list. The whole list, what makes each one appear and disappear, and the **disable hints** row on the Workspace tab that turns them off, are on the *hints and tips* page. @@ -1475,10 +1475,9 @@ and not decoration. It also goes while a turn is running, where the same row has 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.) -**It stays when a tip is showing.** After the first exchange the keys row often carries a -tip instead of the controls, and the door is kept after it — -`/files finds files codeaf wrote for you · space space home`. Until 2026-09-24 the tip took -the whole row and the door went with it. One of the tips is about the door itself: +**It stays when a tip is showing.** A conversation's tip covers the project at the keys +row's right end, never the keys. Until 2026-09-24 a tip took the whole row and the +door went with it. One of the tips is about the door itself: `space space takes you back to home`. **You can click it.** A press on the words `space space home` opens home; a press on the diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 1806bb286..68ac57fe4 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2203,8 +2203,8 @@ 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. When a tip has -taken the row it still ends with `· space space home`. +`space space home` opens home; that clause vanishes as soon as you type. A tip never takes +its place: a conversation's tip covers the project at the row's right end instead. **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 diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 9e4361beb..3bdb019ad 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -214,9 +214,9 @@ on home and in conversations. On both boxes `project: ` is at the right en conversation opens, a conversation's names its own workspace. The keys keep their room: a long path truncates on the right, and the field disappears if there is less than a word of room. **On home** the dim line above the rule, when there is one, is a tip (see *hints -and tips*), there from the first minute. **In a conversation** the tip is not that row at -all: it is the lowest rung of the keys row itself, taking the slot from the rest state -whenever nothing else is happening. The bottom row names the available project, effort +and tips*), there from the first minute. **In a conversation** the tip covers `project:` at the +keys row's right end, bulb and cross included, after 15 quiet seconds, and the project is +back when it goes. The bottom row names the available project, effort and approval controls; the cells can also be pressed: | cell | chord | or | diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 75ac352da..6122305e3 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2311,6 +2311,13 @@ type app struct { footRow int tipRow int tipCloseSpan hudSpan + // chatTipClose is the columns of the cross on a conversation's tip, at the + // right end of its keys row (footswap.go's [app.hintRow]). tipQuietFrom is + // when this window last stirred, and tipAlarm the one pending alarm of the + // conversation's quiet clock, or zero (notice.go's THE CONVERSATION'S TIP). + chatTipClose hudSpan + tipQuietFrom time.Time + tipAlarm time.Time // targetEffortSpan and targetApprovalSpan are the rung's and the gate's // columns on that same line — the draft's twins of [app.seamEffortSpan] and // [app.seamApprovalSpan] (boxseam.go), recorded on the same bargain. @@ -3318,6 +3325,13 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.levelsWaiting() || a.waiting() || a.formingCardLive() { cmd = tea.Batch(cmd, a.wake()) } + // AND THE CONVERSATION'S TIP WAITS OUT ITS QUIET HERE, on the one line every + // message passes: whatever the message did — a key restarting the wait, a + // turn ending, the alarm itself landing early — this is where the next + // alarm is set, and only ever one (notice.go's [app.chatTipAlarm]). + if alarm := a.chatTipAlarm(); alarm != nil { + cmd = tea.Batch(cmd, alarm) + } // AND A CLOCK SOMEBODY STOPPED IS TOLD TO THE ENGINE HERE, from a command // rather than from inside the key routine that took the key // (questionhold.go's [app.takeQuestionHolds] says why it is this line). @@ -3403,8 +3417,17 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // on the way out. return a, a.quit() + case chatTipDueMsg: + // THE QUIET CLOCK'S ALARM. It carries nothing: the frame drawn after it + // asks the clock afresh, and a wait a key restarted meanwhile is set + // again for what is left of it ([app.chatTipAlarm], in [app.Update]). + a.tipAlarm = time.Time{} + a.touch() + return a, nil + case tea.KeyPressMsg: a.sawAPerson() + a.stirred() // AND THE HAND IS STAMPED HERE, because this is the only line every // keypress passes through, and what the question block needs to know // is whether somebody is at the keyboard at all: @@ -3541,6 +3564,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case tea.PasteMsg: + a.stirred() // Bracketed paste, whole, in one message — the parser coalesced the keys // between the brackets for us, so the newlines inside it are text and not // a stack of enters. Inside an open bracket it JOINS what the bracket has @@ -3668,6 +3692,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseWheelMsg: a.clearPlaceRowHover() + a.stirred() a.placePointer.suspended = true // THE CONTEXT CHOOSER OWNS THE WHEEL WHILE IT IS UP, and it owns it over // the WHOLE screen: the conversation under a modal is not live, so a wheel @@ -3874,6 +3899,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseClickMsg: a.clearPlaceRowHover() a.sawAPerson() + // THE QUIET CLOCK RESTARTS AFTER THE PRESS IS ANSWERED, not before. A + // press is resolved against the chrome laid out afresh ([app.chromeAt]), + // and a clock restarted first would have taken the tip row — and the + // cross being pressed — off that layout (notice.go's [app.chatTipPress]). + defer a.stirred() // AND IT OWNS THE PRESS, on the same terms and for a sharper reason: a // press that fell through a modal would switch a tab, open a tool call or // answer a question behind a sheet somebody is looking at @@ -4062,6 +4092,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // rather than in the box, and column-aware for the same reason: the // row it rides is empty everywhere else, and empty space on this // surface is not a gesture (jumpchip.go). + if a.chatTipPress(msg.Mouse().X, msg.Mouse().Y) { + return a, nil + } if a.jumpPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } @@ -5825,6 +5858,9 @@ func (a *app) settle() tea.Cmd { a.hopQuick = config.QuickSwitchAt(a.profileDir) a.askWait = a.consentWait() a.notices.enabled = config.HintsAt(a.profileDir) + // AND THE ANSWER HAS JUST ARRIVED, which is when somebody starts reading + // it: the conversation's tip waits its quiet out from here (notice.go). + a.stirred() // A turn ending is the moment most hints become true — the answer was long, // the window is half full, the money is real — so it is the event they are // decided on, and it is the turn [noticeGap] is counted in (notice.go). @@ -7487,6 +7523,9 @@ func (a *app) openConversation(file string) (Conversation, bool, error) { // draft and the trio. That is what `whole` says, and it is the caller's own fact // rather than something guessed from the fields. func (a *app) takeUp(conv Conversation, whole bool) { + // ANOTHER CONVERSATION IS ANOTHER ROW: a cross pressed over the one being + // left does not blank the one arriving (notice.go's [app.liftChatTipCross]). + a.liftChatTipCross() if conv.Agent != nil { a.agent = conv.Agent // AND EVERY DOOR ASKED OF THE CONVERSATION BEING PUT DOWN IS NOW A DOOR diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index 11ea78e48..ef671a135 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -15,12 +15,9 @@ import ( // ── THE CONVERSATION'S TIP, ITS KEYS ROW'S PROJECT, AND TWO DOORS ─────────── // -// A conversation's tip is the LOWEST RUNG OF THE KEYS ROW at the foot, decided -// by the events that prove what is happening and drawn whenever the frame is -// quiet. It had a row of its own over the rule for one build on 2026-09-22 — -// with a quiet minute before it appeared, a two-minute rotation and a cross — -// and the owner put it back here. Home's row keeps that newer shape, and -// hometip_test.go holds it to that. The project came down off the seam to the +// A conversation's tip stands at the RIGHT END OF ITS KEYS ROW, over the +// project, with home's bulb and a cross, once the conversation has been quiet +// for fifteen seconds (2026-09-24). The project came down off the seam to the // right end of the keys row, as it did on home. And two of the owner's bug // reports from the same day: enter on `/attach` in the list opens the browser // at once, and the search place finds conversations by name when memory is off. @@ -48,100 +45,203 @@ func tipRowOf(a *app, tip string) (int, []string) { return -1, rows } -// THE TIP IS THE KEYS ROW'S LOWEST RUNG, on no clock at all: the event that -// arms it puts it there, and it is drawn from that moment on while the frame is -// quiet. A key in the box takes the row back because the row belongs to the -// sentence being written, and emptying the box gives it back at once — no -// minute, no beat, no cross. -func TestAConversationSaysItsTipOnTheKeysRow(t *testing.T) { +// THE TIP COVERS THE PROJECT WHILE IT IS UP (2026-09-24): the right end of +// the keys row, with home's bulb and a cross, one cell in from the edge. The +// controls keep their place at the row's left. +func TestAConversationsTipCoversTheProjectOnTheKeysRow(t *testing.T) { a, _ := chatTipLab(t) + a.tilde, a.workspace = "/home/person", "/home/person/projects/parser" b := &a.notices makeDeliverable(t, a) if b.current[slotHint] != "files-after-first-deliverable" { t.Fatalf("an export landing armed %q", b.current[slotHint]) } - if got := a.noticeHint(); got != deliverTip { - t.Fatalf("the tip is not up the moment it arms: %q", got) - } - - // ON THE FRAME: the foot, under the box, and NOT a row of its own over the - // rule. - if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { - t.Fatalf("the keys row does not carry the tip: %q", got) + if got := a.chatTip(); got != deliverTip { + t.Fatalf("a window nothing has stirred does not say its tip: %q", got) } y, rows := tipRowOf(a, deliverTip) - if y < 0 { - t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) - } - if y+1 < len(rows) && strings.HasPrefix(rows[y+1], "─") { - t.Fatalf("the tip is sitting over the rule again:\n%s", strings.Join(rows, "\n")) + if y != markedRowY(a, a.hintRowKind(), 0) { + t.Fatalf("the tip is not on the keys row:\n%s", strings.Join(rows, "\n")) } - // AND IT WEARS NO BULB AND NO CROSS. Those belong to home's row. + row := plain(a.hintRow(a.width)) cross := a.pal.glyph(tokens.GFailed) - if row := rows[y]; strings.Contains(row, homeTipLead) || strings.HasSuffix(strings.TrimRight(row, " "), cross) { - t.Fatalf("the conversation's tip wears home's bulb or cross: %q", row) + if got := ansi.StringWidth(row); got != a.width { + t.Fatalf("the keys row measures %d cells on a %d-cell frame", got, a.width) } - - // A LETTER IN THE BOX TAKES THE ROW; emptying it gives the row back. - drive(t, a, key("x")) - if got := a.noticeHint(); got != "" { - t.Fatalf("the tip drew over a box with a letter in it: %q", got) + if !strings.HasSuffix(row, homeTipLead+homeTipGap+deliverTip+homeTipGap+cross+" ") { + t.Fatalf("the keys row does not end with the bulb, the tip and its cross: %q", row) + } + if !strings.HasPrefix(row, " "+plain(a.footHint(a.width))) { + t.Fatalf("the tip cost the controls their place: %q", row) } - drive(t, a, key("backspace")) - if got := a.noticeHint(); got != deliverTip { - t.Fatalf("emptying the box did not give the row back: %q", got) + if strings.Contains(row, targetProjectLead) || a.seamProjectSpan.pressable() { + t.Fatalf("the project is still on the row under the tip: %q", row) + } + for width := 1; width <= 240; width++ { + if line := plain(a.hintRow(width)); ansi.StringWidth(line) > width { + t.Fatalf("at %d cells the keys row overflowed: %q", width, line) + } } - // A RUNNING TURN TAKES IT TOO, and every state with keys of its own. + // A RUNNING TURN, A PLACE, A LETTER IN THE BOX: each takes the tip away, + // and the project is back. a.state = stateWorking - if got := a.noticeHint(); got != "" { + if got := plain(a.hintRow(a.width)); strings.Contains(got, deliverTip) || !strings.Contains(got, targetProjectLead) { t.Fatalf("the tip drew over a running turn: %q", got) } a.state = stateIdle a.showPage(pageSpend) - if got := a.noticeHint(); got != "" { + if got := a.chatTip(); got != "" { t.Fatalf("the tip drew under a place: %q", got) } a.leavePlace() - if got := a.noticeHint(); got != deliverTip { - t.Fatalf("leaving the place did not give the row back: %q", got) + a.input.setText("x") + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew over a box with a letter in it: %q", got) } } -// THE DOOR HOME STAYS BESIDE THE TIP (2026-09-24). The tip used to take the -// whole keys row, so from a conversation's first exchange onward -// `space space home` was named nowhere on it. It rides after the tip now, and a -// narrow frame gives up the tip before the door. -func TestTheDoorHomeStaysBesideTheConversationsTip(t *testing.T) { - a, _ := chatTipLab(t) +// THE TIP STANDS WITH THE TASK COLUMN OPEN OR PUT AWAY (the owner's report, +// 2026-09-24): a column put away — whose keys row then says `ctrl+g tasks` — +// does not silence it. +func TestTheConversationsTipStandsWithTheColumnOpenOrAway(t *testing.T) { + a, _, _ := taskApp(t) + a.profileDir = t.TempDir() + a.open = func(string, string) (Conversation, error) { return Conversation{}, nil } + a.width, a.height = 180, 40 + railRun(a) + makeDeliverable(t, a) + a.tipQuietFrom = time.Time{} + check := func(state string) { + t.Helper() + y, rows := tipRowOf(a, deliverTip) + if y < 0 || y != markedRowY(a, a.hintRowKind(), 0) { + t.Fatalf("%s: the tip is not on the keys row:\n%s", state, strings.Join(rows, "\n")) + } + } + if !a.railShowing() { + t.Fatal("the task column is not up for the open half of this test") + } + check("column open") + drive(t, a, ctrlG()) + a.tipQuietFrom = time.Time{} + if got := plain(a.footHint(a.width)); got != railBackHint { + t.Fatalf("the keys row of a put-away column reads %q, want %q", got, railBackHint) + } + check("column put away") +} + +// FIFTEEN SECONDS OF QUIET, ON CONVERSATIONS ONLY (2026-09-24). A key starts +// the wait again and takes the row down, and so does a turn ending; the tip +// is back once the conversation has been left alone for [chatTipQuiet], and +// the one alarm that draws it is set by the loop itself. +func TestTheConversationsTipWaitsForFifteenQuietSeconds(t *testing.T) { + a, advance := chatTipLab(t) makeDeliverable(t, a) - want := deliverTip + hintSegment + homeDoorWord - if got := plain(a.footHint(a.width)); got != want { - t.Fatalf("the keys row reads %q, want %q", got, want) + drive(t, a, key("x"), key("backspace")) + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew straight after a key: %q", got) + } + if y, _ := tipRowOf(a, deliverTip); y >= 0 { + t.Fatal("the tip is on the frame straight after a key") } - if y, rows := tipRowOf(a, want); y < 0 { - t.Fatalf("the frame does not carry the tip and the door together:\n%s", strings.Join(rows, "\n")) + // THE LOOP SET ONE ALARM for the end of the wait, and only one however + // many keys arrive while it is pending. + if a.tipAlarm.IsZero() { + t.Fatal("no alarm was set for the end of the wait") } - if !a.homeDoor.holds(a.homeDoor.from) { - t.Fatal("the door beside the tip was drawn but not recorded for a click") + first := a.tipAlarm + advance(5 * time.Second) + drive(t, a, key("x"), key("backspace")) + if a.tipAlarm != first { + t.Fatalf("a second alarm was set while one was pending: %v then %v", first, a.tipAlarm) } - if got := a.hintShorter(want); got != homeDoorWord { - t.Fatalf("a narrow frame kept %q, want the door alone", got) + advance(chatTipQuiet - time.Second) + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew a second before the wait was up: %q", got) } - if got := a.hintShorter(homeDoorWord); got != "" { - t.Fatalf("the door alone shortened to %q", got) + // The first alarm lands early — the wait moved — and the loop sets the next. + drive(t, a, chatTipDueMsg{}) + if a.tipAlarm.IsZero() { + t.Fatal("the alarm that landed early did not set the next one") } + advance(time.Second) + if got := a.chatTip(); got != deliverTip { + t.Fatalf("the tip is not up after fifteen quiet seconds: %q", got) + } + drive(t, a, chatTipDueMsg{}) + if y, rows := tipRowOf(a, deliverTip); y < 0 { + t.Fatalf("the tip is not on the frame after the wait:\n%s", strings.Join(rows, "\n")) + } + if !a.tipAlarm.IsZero() { + t.Fatal("an alarm is still set for a tip that is already up") + } + + // A TURN ENDING STARTS THE WAIT AGAIN: the answer is what is being read. + a.settle() + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew straight after a turn ended: %q", got) + } + // AND HOME DOES NOT WAIT: its row is its own and is said at once. + advance(time.Second) + runCmd(a.showPage(pageHome)) + if a.noticeHomeHint() == "" { + t.Fatal("home's row waited on the conversation's quiet clock") + } +} - // A letter in the box takes the tip and the door together. - drive(t, a, key("x")) - if got := plain(a.footHint(a.width)); strings.Contains(got, homeDoorWord) { - t.Fatalf("the door is still named over a box with a letter in it: %q", got) +// THE CROSS PUTS THE TIP AWAY, and gives the project back, until the +// conversation is left and come back to, as home's does until home is +// (2026-09-24). The tip put away is not retired. +func TestTheCrossPutsTheConversationsTipAwayUntilItIsLeft(t *testing.T) { + a, _ := chatTipLab(t) + a.tilde, a.workspace = "/home/person", "/home/person/projects/parser" + makeDeliverable(t, a) + y, rows := tipRowOf(a, deliverTip) + if y < 0 { + t.Fatalf("the tip is not on the frame:\n%s", strings.Join(rows, "\n")) + } + if !a.chatTipClose.pressable() { + t.Fatal("the tip row recorded no cross") + } + // A press beside the cross is a press on nothing. + if a.chatTipPress(a.chatTipClose.from-3, y) { + t.Fatal("a press on the tip's words was taken as the cross") + } + // THE REAL PRESS, through the loop: the click restarts the quiet clock, and + // it must do so only after the cross has been found under it. + drive(t, a, tea.MouseClickMsg{X: a.chatTipClose.from, Y: y, Button: tea.MouseLeft}) + if !a.notices.hidden[slotHint] { + t.Fatal("a click on the cross, through the loop, did not put the row away") + } + a.tipQuietFrom = time.Time{} + if got := a.chatTip(); got != "" { + t.Fatalf("the row still says %q after its cross", got) + } + if y, _ := tipRowOf(a, deliverTip); y >= 0 { + t.Fatal("the tip is still on the frame after its cross") + } + if got := plain(a.hintRow(a.width)); !strings.Contains(got, targetProjectLead+"~/projects/parser") { + t.Fatalf("the project did not come back after the cross: %q", got) + } + if a.notices.retired("files-after-first-deliverable") { + t.Fatal("the cross retired the tip it put away") + } + // Staying put does not bring it back; leaving and coming back does. + a.settle() + if got := a.chatTip(); got != "" { + t.Fatalf("a turn ending lifted the cross: %q", got) + } + a.showPage(pageSpend) + a.leavePlace() + a.tipQuietFrom = time.Time{} + if got := a.chatTip(); got != deliverTip { + t.Fatalf("coming back to the conversation did not lift the cross: %q", got) } } // THE TIP ABOUT THE DOOR (2026-09-24) is said in a conversation after its first -// exchange and never on home, and while it stands the row does not name the -// door a second time after it. +// exchange and never on home. func TestTheWayHomeTipIsSaidAwayFromHomeOnly(t *testing.T) { var row notice for _, n := range notices { @@ -167,10 +267,6 @@ func TestTheWayHomeTipIsSaidAwayFromHomeOnly(t *testing.T) { if row.armed(a) { t.Fatal("the way-home tip is armed on home itself") } - a.leavePlace() - if got := a.tipWithHomeDoor(row.text); got != row.text { - t.Fatalf("the way-home tip named the door twice: %q", got) - } } // Silencing hints silences the conversation's row along with home's. @@ -184,8 +280,11 @@ func TestDisableHintsSilencesTheConversationRow(t *testing.T) { if got := a.noticeHint(); got != "" { t.Fatalf("a silenced profile still says %q in a conversation", got) } - if got := plain(a.footHint(a.width)); strings.Contains(got, deliverTip) { - t.Fatalf("a silenced profile still draws the tip on the keys row: %q", got) + if got := a.chatTip(); got != "" { + t.Fatalf("a silenced profile still draws the conversation's tip row: %q", got) + } + if y, _ := tipRowOf(a, deliverTip); y >= 0 { + t.Fatal("a silenced profile still draws the tip on the frame") } } diff --git a/internal/tui3/footswap.go b/internal/tui3/footswap.go index a67f9ce60..90b6b7764 100644 --- a/internal/tui3/footswap.go +++ b/internal/tui3/footswap.go @@ -229,9 +229,16 @@ func (a *app) seamTelemetryLabel(ledger, alive []hudPart) (string, string) { // two feet a person moves between most read the same way, and it is still // a door — onto the folder chooser ([app.seamProjectPress]) — so its columns // are recorded here, as the row is laid out ([app.seamProjectSpan]). +// +// AND THE TIP COVERS THE PROJECT WHILE IT IS UP, since 2026-09-24 at the +// owner's word (notice.go's THE CONVERSATION'S TIP): the same right end, the +// same fitting to what the keys leave, with home's bulb before it and a cross +// after it whose columns are recorded here too ([app.chatTipClose]). The +// project is back the moment the tip goes. func (a *app) hintRow(width int) string { a.homeDoor = hudSpan{} a.seamProjectSpan = hudSpan{} + a.chatTipClose = hudSpan{} hint := a.footHint(width) right, rightPlain := "", "" if !a.seamShowing() { @@ -289,6 +296,18 @@ func (a *app) hintRow(width int) string { if warned != "" { before += 1 + ansi.StringWidth(warning) } + if tip := a.chatTip(); tip != "" { + if drawn, cross := a.tipLine(tip, width-before-hudGap, a.pal); drawn != "" { + drawn = strings.TrimLeft(drawn, " ") + w := ansi.StringWidth(drawn) + a.chatTipClose = hudSpan{from: width - 1 - (cross.to - cross.from), to: width - 1} + if warned != "" { + pad := width - 1 - used - ansi.StringWidth(warning) - hudGap - w + return line + strings.Repeat(" ", max(1, pad)) + warned + strings.Repeat(" ", hudGap) + drawn + " " + } + return line + strings.Repeat(" ", width-1-used-w) + drawn + " " + } + } // THE PROJECT, in what the keys leave — never inside a room, whose page // carries the node's own identity (roomseam.go). if project := a.seamProjectWord(); project != "" && !a.roomOpen() { diff --git a/internal/tui3/hometip.go b/internal/tui3/hometip.go index a0b5e58d2..c68e571a6 100644 --- a/internal/tui3/hometip.go +++ b/internal/tui3/hometip.go @@ -21,9 +21,10 @@ import ( // above where the rule used to say the project (the owner's placing, // 2026-09-22). It is led by a bulb and closed by a cross a pointer can press: // the cross puts the tip away until the row next changes hands -// ([app.noticeDismiss]). The same row, laid out by the same function, stands -// over a conversation's box (view.go's [app.chrome]) once the person has been -// quiet there for a minute (notice.go's THE CONVERSATION'S CLOCK). +// ([app.noticeDismiss]). The same bulb, tip and cross, laid out by the same +// function, stand over a conversation's project at the right end of its keys +// row (footswap.go's [app.hintRow]) once the person has been quiet there for +// [chatTipQuiet] (notice.go's THE CONVERSATION'S TIP). // // THE PROJECT IS ON THE KEYS ROW NOW, right-justified, and it is the keys that // keep their room: the path gives up its right end, one ellipsis, where the diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index b6279e1b3..e108cb67a 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -344,22 +344,24 @@ func TestTheCrossBlanksHomesRowAndSpendsNothingOfTheTipItPutAway(t *testing.T) { } } -// THE CROSS IS HOME'S ALONE. A conversation says its tip on the keys row -// (chattip_test.go), and a keys row has never had one: there is no span for a -// press to land in, and nothing on that row is a door. -func TestAConversationsTipRowCarriesNoCross(t *testing.T) { +// A CONVERSATION'S CROSS IS ITS OWN (2026-09-24). Its tip stands at the right +// end of its keys row with a cross of its own, recorded on its own span, and +// home's span is not written by a conversation's frame (chattip_test.go holds +// the row). +func TestAConversationsTipRowCarriesItsOwnCross(t *testing.T) { a, _ := sheetApp(t) makeDeliverable(t, a) - if a.noticeHint() == "" { + if a.chatTip() == "" { t.Fatal("the conversation says no tip to begin with") } frame(a) if a.tipCloseSpan.pressable() { - t.Fatalf("a conversation drew a cross at columns %+v", a.tipCloseSpan) + t.Fatalf("a conversation wrote home's cross at columns %+v", a.tipCloseSpan) } - // And the tip is on the keys row rather than on a row of its own with a - // cross at the end of it. - if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { + if !a.chatTipClose.pressable() { + t.Fatal("the conversation's tip row recorded no cross") + } + if got := plain(a.hintRow(a.width)); !strings.Contains(got, deliverTip) { t.Fatalf("the conversation's tip is not on the keys row: %q", got) } } diff --git a/internal/tui3/hometiplayout_test.go b/internal/tui3/hometiplayout_test.go index 1f3d1ec1a..c9899bfec 100644 --- a/internal/tui3/hometiplayout_test.go +++ b/internal/tui3/hometiplayout_test.go @@ -189,6 +189,13 @@ func TestDisableHintsIsAWorkspaceRowThatReadsTheOtherWayUp(t *testing.T) { if item.meta.tab != tabWorkspace || item.meta.label != "disable hints" { t.Fatalf("the hints row is %q on the %s tab, want \"disable hints\" on Workspace", item.meta.label, item.meta.tab) } + // THE LINE UNDER IT IS THE OWNER'S OWN WORDING (2026-09-24). + if want := "disable💡 tips everywhere (requires restart)"; item.meta.about != want { + t.Fatalf("the line under the hints row reads %q, want %q", item.meta.about, want) + } + if !strings.Contains(plain(frame(a)), "disable💡 tips everywhere (requires restart)") { + t.Fatal("the panel does not draw the line under the selected hints row") + } if got := item.row.Value(); got != "off" { t.Fatalf("a fresh profile reads %q, want off (hints shown)", got) } diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 97caad323..8d04b3bdb 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -9,6 +9,8 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/buildinfo" ) @@ -19,7 +21,7 @@ import ( // // - EARNED HINTS. One dim line beside the box — `/files finds files codeaf // wrote for you` — on the row directly above the rule over home's box, and -// on the lowest rung of a conversation's keys row. A tip RETIRES FOR GOOD +// over the project at the right of a conversation's keys row. A tip RETIRES FOR GOOD // the first time the gesture it teaches is used (the files place opened), // or after it has been shown [noticeShownDefault] times without being // acted on. A hint that stays @@ -71,9 +73,9 @@ import ( type noticeSlot uint8 const ( - // slotHint is the lowest rung of a conversation's keys row at the foot - // (render.go's [app.footHint] draws it), taken whenever the frame is quiet - // enough for a tip to be read over an idle box ([app.noticeHint]). + // slotHint is a conversation's tip, at the right end of its keys row over + // the project (footswap.go's [app.hintRow], [app.chatTip]), drawn once the + // conversation has been quiet for [chatTipQuiet] over an idle box. slotHint noticeSlot = iota // slotNote is one calm transcript line through [feed.note]. It is reserved // for news: a hint belongs beside the box it is about, and a line in the @@ -401,9 +403,9 @@ var notices = []notice{ // its way out, and a tip is a thing to teach somebody who will still have // it tomorrow. The command itself is untouched. { - // THE WAY BACK, AT THE OWNER'S WORD ON 2026-09-24. The foot names the - // door as `space space home` beside every tip ([app.footHint]), but a - // label of three words reads as chrome after the first day, and this + // THE WAY BACK, AT THE OWNER'S WORD ON 2026-09-24. The controls name + // the door as `space space home` ([app.idleHint]), but a label of three + // words reads as chrome after the first day, and this // row says what it is in a sentence. It is never armed on home, where // there is nowhere to go back to, and it retires on the gesture itself // rather than on reaching home by `/home` or the tab. IT RANKS FIRST of @@ -676,9 +678,10 @@ type noticeBoard struct { // counted from it when the tip leaves or the row goes out of sight // ([noticeBoard.settle]), and only if it stood [noticeReadTime]. since [noticeSlots]time.Time - // hidden is the cross on home's row having been pressed: the row draws - // nothing at all until HOME ITSELF GOES OUT OF VIEW ([app.dropHome]), - // which is the only thing that lifts it. Deciding the slot again does not + // hidden is the cross on a tip row having been pressed: the row draws + // nothing at all until HOME ITSELF GOES OUT OF VIEW ([app.dropHome]), or + // for a conversation's row until the conversation does + // ([app.liftChatTipCross]), which is the only thing that lifts it. Deciding the slot again does not // lift it, and neither does the two-minute beat: a cross answered with // another sentence on the same screen is the surface talking over somebody // who asked it to stop (the owner's ruling, 2026-09-22). It is this @@ -886,8 +889,7 @@ func (b *noticeBoard) rotate(slot noticeSlot, cands []noticeCandidate) string { // how long it stood ([noticeBoard.settle]). A slot re-decided to the same tip // is nothing at all, which is what keeps an hour of events on one tip at one // showing; and a slot deciding while its row cannot be seen — home's while a -// conversation is in front, the conversation's before its quiet minute — -// starts no standing, because what has not been read has not been shown +// conversation is in front — starts no standing, because what has not been read has not been shown // ([app.noticeLive]). Until 2026-09-22 every visible change of hands counted, // and [noticeReadTime] says what that cost. func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, limitOf func(string) int, turn int) (changed, wrote bool) { @@ -898,9 +900,9 @@ func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, b.current[slot] = id if slot == slotHint { // THE CONVERSATION'S ROW COUNTS ON ARRIVAL, ONCE PER SESSION. Its tip - // stands on the keys row for as long as the frame is quiet, with no - // clock over it and no cross to end it, so there is no departure to - // measure — and an hour in the slot is one showing, not one per event. + // comes and goes with the quiet clock ([app.chatTip]), so there is no + // one departure to measure — and an hour in the slot is one showing, + // not one per event. b.lastHintTurn = turn if id == "" || b.shown[id] { return true, wrote @@ -1036,7 +1038,7 @@ func (a *app) noticeFill(slot noticeSlot) bool { // noticeLive is whether a slot's row can be seen at all right now — which is // what makes a change of hands a showing ([noticeBoard.take]): home's row -// while home is in front, the conversation's once its quiet minute has passed. +// while home is in front. // // A ROW WHOSE CROSS HAS BEEN PRESSED IS NOT LIVE. It draws nothing until home // goes out of view ([noticeBoard.hidden]), and a tip standing behind a blank @@ -1155,9 +1157,9 @@ func (a *app) noticeHomeQuiet() bool { // noticeDismiss is the cross on a tip row, and what it means is ENOUGH OF // THESE FOR NOW — not "say something else". The row goes blank and STAYS // blank for the rest of this sitting: on home, until home is left and come -// back to; in a conversation, until the row goes out of sight under a key and -// the next quiet minute brings it back ([noticeBoard.hidden] names both, and -// they are the same law — the cross is lifted by the row going out of view). +// back to; in a conversation, until the conversation is left and come back to +// ([noticeBoard.hidden] names both, and they are the same law — the cross is +// lifted by the row's screen going out of view). // // THE OWNER'S RULING, 2026-09-22: "do not show another hint until the user // comes back to the home tab after leaving it". A cross answered with a second @@ -1193,16 +1195,9 @@ func (a *app) noticeShow(slot noticeSlot, id string) { } } -// noticeHint is the conversation's tip: the lowest rung of the KEYS ROW at the -// foot (render.go's [app.footHint]), drawn whenever the frame is quiet enough -// for a tip to be read over an idle box. -// -// IT IS ON NO CLOCK AND HAS NO CROSS. For one build on 2026-09-22 it had both -// — a row of its own over the rule, a quiet minute before it appeared, a -// two-minute rotation and a cross — and the owner put it back where it was: -// the foot, decided by the events that prove what is happening, shown while -// nothing is happening. Home's row keeps the newer shape; the two boxes are -// read differently and are allowed to differ ([noticeBoard.pick]). +// noticeHint is the tip standing in the conversation's slot, whenever the frame +// is quiet enough for a tip to be read over an idle box — before the quiet +// clock and the cross are asked ([app.chatTip] asks them). // // IT DRAWS OVER NOTHING THAT IS HAPPENING. A running turn, a list, a layer, a // box with words in it — each of those belongs to the thing being done, and @@ -1216,6 +1211,109 @@ func (a *app) noticeHint() string { return a.chords.say(a.noticeLine(id)) } +// ── THE CONVERSATION'S TIP ────────────────────────────────────────────────── +// +// A conversation's tip stands at the right end of its keys row, over the +// project, led by home's bulb and closed by a cross (hometip.go's +// [app.tipLine], footswap.go's [app.hintRow]) — the owner's placing on +// 2026-09-24, after builds that had it replacing the controls, in the gap +// before the project, and on a row of its own above the rule. That last one +// had to be told where the rule's numbers and the task column's divider were; +// the keys row's right end is where the project already stands, wherever the +// column is. +// +// IT WAITS FOR FIFTEEN SECONDS OF QUIET, on conversations alone. A +// conversation is a screen somebody works in, and a sentence appearing under +// the answer they are reading, or between two things they are typing, is the +// surface talking over them. Every key, press, wheel and paste starts the +// wait again ([app.stirred]), and so does a turn ending, because the answer +// arriving is exactly when somebody starts reading. Home's row does not wait: +// home is a screen people pass through. + +// chatTipQuiet is how long a conversation has to be left alone before its tip +// is drawn. +const chatTipQuiet = 15 * time.Second + +// chatTipDueMsg is the one alarm the quiet clock sets, landing when the wait +// it was armed for is up ([app.chatTipAlarm]). +type chatTipDueMsg struct{} + +// stirred is somebody doing something in this window, or the conversation +// giving them something to read: the quiet clock starts again. +func (a *app) stirred() { a.tipQuietFrom = a.now() } + +// chatTipQuietEnough is whether the conversation has been left alone for +// [chatTipQuiet]. A window nothing has stirred yet has been quiet forever. +func (a *app) chatTipQuietEnough() bool { + return a.tipQuietFrom.IsZero() || a.now().Sub(a.tipQuietFrom) >= chatTipQuiet +} + +// chatTipReady is the tip a conversation's keys row may draw, before the quiet +// clock is asked: "" while hints are off, while the cross is down, +// or while the frame is saying anything other than its rest state — a list, a +// panel, a question, the chord diagnosis — because a sentence over keys that +// are not the rest state's would be read as one of them. +func (a *app) chatTipReady() string { + if a.notices.hidden[slotHint] || a.at(pageHome) { + return "" + } + // THE CLOSED TASK COLUMN IS NOT ONE OF THOSE STATES. `ctrl+g tasks` is what + // the keys row says at rest once the column is put away ([railBackHint]), + // and a tip that went quiet whenever it did was a tip that only ever + // appeared with the sidebar open (the owner's report, 2026-09-24). + if word := a.hintWord(); a.questionRoomOpen() || (word != "" && word != railBackHint) || + (a.chordLost && a.chords.meta == chordMetaWord) { + return "" + } + return a.noticeHint() +} + +// chatTip is the tip a conversation's keys row draws over its project: the +// ready tip, once the conversation has been quiet for [chatTipQuiet]. +func (a *app) chatTip() string { + if !a.chatTipQuietEnough() { + return "" + } + return a.chatTipReady() +} + +// chatTipAlarm sets the alarm that draws the tip when its wait is up, and it +// is asked after every message (app.go's [app.Update]). +// +// THERE IS ONLY EVER ONE ALARM PENDING. A key restarts the wait, and arming a +// timer per key would leave a sleeping goroutine per keystroke; instead the +// alarm that lands finds the wait moved and sets the next one for what is +// left of it ([chatTipDueMsg]). +func (a *app) chatTipAlarm() tea.Cmd { + if !a.tipAlarm.IsZero() || a.chatTipQuietEnough() || a.chatTipReady() == "" { + return nil + } + due := a.tipQuietFrom.Add(chatTipQuiet) + a.tipAlarm = due + return surfaceTick(due.Sub(a.now()), func(time.Time) tea.Msg { return chatTipDueMsg{} }) +} + +// chatTipPress is a press on the cross at the end of the conversation's tip. +// It puts the tip away, and gives the project back, until the conversation is left and come back to +// ([app.noticeDismiss], [app.liftChatTipCross]). +func (a *app) chatTipPress(x, y int) bool { + if a.showing() != nil { + return false + } + mark, ok := a.chromeAt(y) + if !ok || mark.kind != a.hintRowKind() || !a.chatTipClose.holds(x) { + return false + } + a.noticeDismiss(slotHint) + return true +} + +// liftChatTipCross is the conversation going out of sight — a place or home +// opening over it, or another conversation taken up — which is the only thing +// that lifts its cross, as leaving home is the only thing that lifts home's +// ([noticeBoard.hidden]). +func (a *app) liftChatTipCross() { a.notices.hidden[slotHint] = false } + // noticeQuiet is whether nothing on the frame outranks a tip. func (a *app) noticeQuiet() bool { return a.input.empty() && a.state != stateWorking && a.showing() == nil && diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 87754532e..7659cb6b9 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -18,7 +18,7 @@ import ( // The notices' own tests (notice.go, notice_ledger.go): the table check, the // ledger's promises, the arbitration as a pure thing, and then the whole road -// through the app — a hint arming on its moment, drawing at the lowest rung, +// through the app — a hint arming on its moment, drawing on the keys row, // retiring on its gesture, and staying retired across a restart. // ── the table ─────────────────────────────────────────────────────────────── @@ -524,8 +524,8 @@ func makeDeliverable(t *testing.T, a *app) { a.exportDone(exportedMsg{path: filepath.Join(t.TempDir(), "talk.md")}) } -// THE WHOLE ROAD. A hint arms on its moment, draws in the hint slot and only at -// the lowest rung there, retires on the gesture it teaches, and is still +// THE WHOLE ROAD. A hint arms on its moment, draws in the hint slot on the keys +// row, retires on the gesture it teaches, and is still // retired when the surface comes up again over the same profile. func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { a, dir := sheetApp(t) @@ -540,15 +540,12 @@ func TestAHintArmsDrawsLowestRetiresAndStaysRetired(t *testing.T) { if got := a.notices.current[slotHint]; got != "files-after-first-deliverable" { t.Fatalf("an export landing armed %q", got) } - // AND IT IS UP THE MOMENT IT ARMS, on the keys row at the foot: the - // conversation's tip is on no clock (chattip_test.go holds the whole of - // where it draws). + // AND IT IS UP THE MOMENT IT ARMS on a window nothing has stirred yet + // (chattip_test.go holds the whole of where it draws and the quiet it + // waits for). if got := a.noticeHint(); got != deliverTip { t.Fatalf("the tip row reads %q, want the tip", got) } - if got := plain(a.footHint(a.width)); !strings.Contains(got, deliverTip) { - t.Fatalf("the keys row does not carry the tip: %q", got) - } if !strings.Contains(plain(frame(a)), deliverTip) { t.Fatalf("the tip is not on the frame:\n%s", plain(frame(a))) } diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index abf9608cf..cef3d9cbc 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -2287,6 +2287,9 @@ func (a *app) showPage(id page) (cmd tea.Cmd) { return nil } a.page = id + // AND THE CONVERSATION GOES OUT OF SIGHT UNDER THE PLACE, which is what + // lifts a cross pressed on its tip row (notice.go's [app.liftChatTipCross]). + a.liftChatTipCross() // AND THE DOOR IS THE GESTURE THE TIPS ABOUT IT WAIT FOR (notice.go): a // place reached by any road retires its tip, and every visit to home // moves home's row on to the next. diff --git a/internal/tui3/projectseam.go b/internal/tui3/projectseam.go index e0a55d7d1..49f5d8e05 100644 --- a/internal/tui3/projectseam.go +++ b/internal/tui3/projectseam.go @@ -60,11 +60,9 @@ func (a *app) seamProjectPress(x, y int) (tea.Cmd, bool) { return a.openFolderPick(""), true } -// THERE IS NO CROSS ON A CONVERSATION'S TIP. `tipClosePress` stood here for -// one build on 2026-09-22, while the tip had a row of its own over the rule; -// the tip is the keys row's lowest rung again (render.go's [app.footHint]) and -// the keys row has never had one. Home's row keeps its cross (home.go's -// [app.homePress]). +// A CONVERSATION'S TIP COVERS THIS DOOR WHILE IT IS UP, at the keys row's +// right end, and the cross at its end is the tip's own (notice.go's +// [app.chatTipPress]); the project comes back the moment the tip goes. // seamModelPaint keeps the current model bold and bright even while underlined. func seamModelPaint(pal palette, text string, hovered bool) string { diff --git a/internal/tui3/render.go b/internal/tui3/render.go index d07dddff5..54c37994f 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3727,38 +3727,9 @@ func (a *app) footHint(width int) string { if a.chordLost && a.chords.meta == chordMetaWord { return a.chords.chordShortWords() } - // AND UNDER EVERY STATE'S OWN KEYS, THE EARNED TIP (notice.go). It is the - // lowest rung there is — a tip about a gesture the person has not used yet, - // drawn only over an idle box — and it takes the slot from the rest state - // below because that is what the rest state is for: the one line a newcomer - // reads when nothing is happening. - // - // IT LEFT THIS ROW FOR ONE BUILD ON 2026-09-22, for a row of its own over - // the rule with a clock and a cross, and the owner put it back here. Home's - // row keeps that newer shape; the two boxes are read differently and are - // allowed to differ (notice.go's [noticeBoard.pick]). - // - // AND THE DOOR HOME STAYS BESIDE IT, since 2026-09-24 at the owner's word. - // The tip used to take the whole row, so from a conversation's first - // exchange onward `space space home` was gone — drawn on a task page, - // where no tip stands, and nowhere a person actually talks. A tip that is - // itself about the door does not say it twice. - if tip := a.noticeHint(); tip != "" { - return a.tipWithHomeDoor(tip) - } return a.idleHint() } -// tipWithHomeDoor is the earned tip followed by the door home, when the door is -// showing and the tip does not already teach it. [app.hintShorter] gives the -// tip up first on a narrow frame, because the door is the one that stays true. -func (a *app) tipWithHomeDoor(tip string) string { - if !a.homeDoorShowing() || strings.Contains(tip, "space space") { - return tip - } - return tip + hintSegment + homeDoorWord -} - // idleHint keeps the shared controls in home's order, followed by the way home. // Each control is named only where the session can take its key. func (a *app) idleHint() string { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 301868298..7bca1b981 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -641,9 +641,9 @@ var settingUI = map[string]settingMeta{ // this door and `codeaf config` say the same word. config.KeyHints: { tab: tabWorkspace, label: "disable hints", widget: widgetToggle, - about: "on silences the one-line tips — home's row above the rule and the " + - "keys row's — and what's-new lines with them. Off shows each until you " + - "have used what it teaches.", + // NO `about` OF ITS OWN: the line under the row is the registry's hint, + // the owner's own wording (2026-09-24), so the panel and `codeaf config` + // say it once ([settingMetaFor]). }, config.KeySplitPct: { tab: tabDisplay, label: "chat width", widget: widgetText, diff --git a/internal/tui3/steer.go b/internal/tui3/steer.go index 4542f3200..5d2c48417 100644 --- a/internal/tui3/steer.go +++ b/internal/tui3/steer.go @@ -487,12 +487,6 @@ func (a *app) runHint() string { // Idle controls yield from the left to commands and home; running hints keep // their existing priority from the left. Every step removes at least one clause. func (a *app) hintShorter(slot string) string { - // A TIP WITH THE DOOR BESIDE IT gives up the tip and keeps the door - // (render.go's [app.tipWithHomeDoor]). A tip alone yields nothing, as it - // always did: half a sentence is not a tip. - if slot != homeDoorWord && strings.HasSuffix(slot, hintSegment+homeDoorWord) && slot == a.tipWithHomeDoor(a.noticeHint()) { - return homeDoorWord - } idle := a.idleHint() if slot != "" && (slot == idle || strings.HasSuffix(idle, hintSegment+slot)) { parts := strings.Split(slot, hintSegment) diff --git a/internal/tui3/view.go b/internal/tui3/view.go index c93551424..e62dc4dd3 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -675,9 +675,8 @@ func (a *app) chrome(width int) ([]string, []chromeRow, int, int) { chip := a.jumpChip(width) jumped := false // addGap spends one row of the ladder, and hands it to the chip if the chip - // has not been placed yet. THE TIP DOES NOT RIDE THIS ROW: it is the keys - // row's lowest rung at the foot (render.go's [app.footHint]), which is - // where it was before 2026-09-22 and where the owner put it back. + // has not been placed yet. THE TIP DOES NOT RIDE THIS ROW: it stands at the + // keys row's right end, over the project (footswap.go's [app.hintRow]). addGap := func() { if chip != "" && !jumped { jumped = true From a447dbf4e52f97813a00f04828d87a7def981c55 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:32:40 -0400 Subject: [PATCH 5/6] chat: going home by two spaces turns home's tips again, and a conversation's tip counts when drawn Review of #1487. The two-space gesture said its event before home opened, so home's row was decided over the conversation, where /project's home-only tip reads as unarmed; it came back fresh and jumped the ring on every trip. The event is said once home is in front now, from a conversation and a place. A conversation's tip is counted as shown when the keys row draws it rather than when the slot takes it, so a tip kept behind the 15-second wait costs none of its showings; the wait holds from launch too; and the manual and the change entry stop naming `codeaf config`, which is not a command. Co-Authored-By: Claude Opus 5.5 --- .../1487-space-space-home-beside-the-tip.md | 5 +- internal/manual/chat/hints-and-tips.md | 10 ++- internal/tui3/app.go | 8 ++ internal/tui3/chattip_test.go | 78 +++++++++++++++++++ internal/tui3/footswap.go | 3 +- internal/tui3/input.go | 3 +- internal/tui3/notice.go | 39 +++++++--- internal/tui3/notice_test.go | 27 +++++-- internal/tui3/placekeys.go | 14 +++- internal/tui3/settings.go | 3 +- 10 files changed, 161 insertions(+), 29 deletions(-) diff --git a/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md index 52b509b31..7b9b68748 100644 --- a/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md +++ b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md @@ -5,8 +5,9 @@ pr: 1487 surface: [chat, docs] invalidates: - "A conversation's earned tip was the keys row's lowest rung and replaced the whole row, so from the first exchange onward the controls and `space space home` were gone while a tip stood. The controls keep their place now, and the tip stands at the keys row's right end, covering `project: ` while it is up, with home's bulb and a cross; the project is back when it goes." - - "A conversation's tip had no clock and no cross. It waits for 15 seconds with no key, click, scroll or paste and none since a turn ended, and its cross puts it away (giving the project back) until the conversation is left (home, a place, another conversation) and come back to. Home's row does not wait." - - "The Workspace tab's `disable hints` row explained itself in a sentence about which rows it silences. The line under it reads `disable💡 tips everywhere (requires restart)`, and `codeaf config` shows the same words. It still silences both rows and the what's-new lines." + - "A conversation's tip had no clock and no cross. It waits for 15 seconds with no key, click, scroll or paste and none since a turn ended or codeaf opened, and its cross puts it away (giving the project back) until the conversation is left (home, a place, another conversation) and come back to. Home's row does not wait." + - "The Workspace tab's `disable hints` row explained itself in a sentence about which rows it silences. The line under it reads `disable💡 tips everywhere (requires restart)`. It still silences both rows and the what's-new lines." + - "A conversation's tip was counted as shown when its slot took it, once per session. It is counted when the keys row first draws it, so a tip hidden behind the 15-second wait all session costs none of its six showings." - "The tip list was twenty-two rows. It is twenty-three: `space space takes you back to home`, the first row a conversation earns by talking (ahead of `/task`), armed only in a conversation whose door home opens and never on home, and retired by the two-space gesture itself (the new `home-gesture` event) — not by `/home` or the tab." --- The controls are the keys that work right now and a tip is only a suggestion, diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 325669a27..f4a42e70b 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -25,8 +25,9 @@ When the tip goes, `project:` is back in its place. When the row is too narrow f whole sentence it is cut short with `…`, and where there is no room for a word of it the project stays. -**It waits for 15 seconds of quiet.** Any key, click, scroll or paste hides it and starts -the 15 seconds again, and so does an answer finishing — the tip never appears under an +**It waits for 15 seconds of quiet**, counted from when codeaf opened at the earliest. +Any key, click, scroll or paste hides it and starts the 15 seconds again, and so does an +answer finishing — the tip never appears under an answer you have just started reading or between two things you are typing. A running turn, a list, a panel, a room, or a box with so much as one letter in it also keep it down. It shows whether the task column is open or put away with `ctrl+g`. @@ -85,7 +86,8 @@ because they behave differently. - **In a conversation, a showing is one session.** However many times the tip comes and goes on the keys row while you work, that is one showing, counted the first time the row - takes it. Six sessions of never acting on it and it is done. + actually draws it — after its 15-second wait — so a tip you never paused long enough to + see is not counted. Six sessions of never acting on it and it is done. - **On home, a showing is a tip that stood twenty seconds or more on a row you could see.** Passing through home for a second or two is not a showing, however many times you do it, and a row deciding while home is not in front is not one either. Until 2026-09-22 every @@ -241,7 +243,7 @@ tab, on by default.) The change lands at the end of the next turn in a conversat already open, and from the start in one opened after — restarting codeaf is the sure way. On silences the tips — over a conversation's box and over home's alike — and the what's-new lines together; it does not touch the keys row's own words for a live state — `esc interrupt` and the rest are not hints and cannot -be turned off. From the terminal, `codeaf config` shows the same row under the same name. +be turned off. Turning the row back off shows whatever is due. Tips you had already retired stay retired. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6122305e3..c34eddb1f 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2315,7 +2315,10 @@ type app struct { // right end of its keys row (footswap.go's [app.hintRow]). tipQuietFrom is // when this window last stirred, and tipAlarm the one pending alarm of the // conversation's quiet clock, or zero (notice.go's THE CONVERSATION'S TIP). + // chatTipDrawn is the id of the tip the keys row last drew, or "", which is + // what a showing is counted from ([app.chatTipCount]). chatTipClose hudSpan + chatTipDrawn string tipQuietFrom time.Time tipAlarm time.Time // targetEffortSpan and targetApprovalSpan are the rung's and the gate's @@ -3193,6 +3196,10 @@ func (a *app) Init() tea.Cmd { // replaying the events that made them, so no arrival ever fires for them // (imagepreview.go's [app.learnShownPictures], learned.go). a.learnShownPictures() + // AND THE WINDOW HAS JUST OPENED, which is when somebody starts reading + // it: a conversation's tip waits out its quiet from here, as it does after + // an answer (notice.go's THE CONVERSATION'S TIP). + a.stirred() // The repository is asked ONCE here and then only at turn ends. A branch is // a fact that changes when a person changes it, and a person who checks out // a branch mid-turn is between two turns by the time it matters. @@ -3332,6 +3339,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if alarm := a.chatTipAlarm(); alarm != nil { cmd = tea.Batch(cmd, alarm) } + a.chatTipCount() // AND A CLOCK SOMEBODY STOPPED IS TOLD TO THE ENGINE HERE, from a command // rather than from inside the key routine that took the key // (questionhold.go's [app.takeQuestionHolds] says why it is this line). diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index ef671a135..110f8aec5 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -269,6 +269,84 @@ func TestTheWayHomeTipIsSaidAwayFromHomeOnly(t *testing.T) { } } +// GOING HOME BY TWO SPACES TURNS HOME'S RING like any other road there (the +// review of #1487). The gesture's event used to be said before home opened, so +// home's row was decided over the conversation, where `/project`'s home-only +// tip reads as unarmed; it came back fresh on every trip and jumped the ring. +func TestGoingHomeByTwoSpacesStillTurnsHomesTips(t *testing.T) { + trips := func(bySpaces bool) []string { + a, _ := chatTipLab(t) + a.turn = 1 + var seen []string + for i := 0; i < 4; i++ { + if bySpaces { + drive(t, a, key(" "), key(" ")) + } else { + runCmd(a.showPage(pageHome)) + } + if !a.at(pageHome) { + t.Fatalf("trip %d did not reach home", i+1) + } + seen = append(seen, a.notices.current[slotHome]) + a.leavePlace() + } + if bySpaces && !a.notices.retired("home-by-two-spaces") { + t.Fatal("two spaces did not retire the way-home tip") + } + return seen + } + bySpaces, byTab := trips(true), trips(false) + if strings.Join(bySpaces, " ") != strings.Join(byTab, " ") { + t.Fatalf("home's row by two spaces went %q, by the tab %q", bySpaces, byTab) + } + distinct := map[string]bool{} + for _, id := range bySpaces { + distinct[id] = true + } + if len(distinct) < len(bySpaces) { + t.Fatalf("home's row did not move on across trips by two spaces: %q", bySpaces) + } +} + +// A TIP NOBODY SAW IS NOT A SHOWING (the review of #1487). The slot takes a +// tip the moment it is true, but the keys row draws it only after the quiet +// clock; a person who never pauses must not spend its showings unseen. +func TestAConversationsTipIsCountedWhenDrawnNotWhenTaken(t *testing.T) { + a, advance := chatTipLab(t) + const id = "files-after-first-deliverable" + drive(t, a, key("x"), key("backspace")) + makeDeliverable(t, a) + frame(a) + drive(t, a, chatTipDueMsg{}) + if a.notices.current[slotHint] != id { + t.Fatalf("the slot holds %q, want %q", a.notices.current[slotHint], id) + } + if got := a.notices.ledger.shown(id); got != 0 { + t.Fatalf("a tip the keys row never drew counted %d showings", got) + } + advance(chatTipQuiet) + frame(a) + drive(t, a, chatTipDueMsg{}) + if got := a.notices.ledger.shown(id); got != 1 { + t.Fatalf("the tip the keys row drew counted %d showings, want 1", got) + } +} + +// THE WAIT HOLDS AT LAUNCH TOO (the review of #1487): a window that has just +// opened is one somebody has just started reading. +func TestTheConversationsTipWaitsFromLaunch(t *testing.T) { + a, advance := chatTipLab(t) + makeDeliverable(t, a) + a.Init() + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew the moment the window opened: %q", got) + } + advance(chatTipQuiet) + if got := a.chatTip(); got != deliverTip { + t.Fatalf("the tip is not up fifteen seconds after launch: %q", got) + } +} + // Silencing hints silences the conversation's row along with home's. func TestDisableHintsSilencesTheConversationRow(t *testing.T) { a, _ := chatTipLab(t) diff --git a/internal/tui3/footswap.go b/internal/tui3/footswap.go index 90b6b7764..651f03d4e 100644 --- a/internal/tui3/footswap.go +++ b/internal/tui3/footswap.go @@ -238,7 +238,7 @@ func (a *app) seamTelemetryLabel(ledger, alive []hudPart) (string, string) { func (a *app) hintRow(width int) string { a.homeDoor = hudSpan{} a.seamProjectSpan = hudSpan{} - a.chatTipClose = hudSpan{} + a.chatTipClose, a.chatTipDrawn = hudSpan{}, "" hint := a.footHint(width) right, rightPlain := "", "" if !a.seamShowing() { @@ -301,6 +301,7 @@ func (a *app) hintRow(width int) string { drawn = strings.TrimLeft(drawn, " ") w := ansi.StringWidth(drawn) a.chatTipClose = hudSpan{from: width - 1 - (cross.to - cross.from), to: width - 1} + a.chatTipDrawn = a.notices.current[slotHint] if warned != "" { pad := width - 1 - used - ansi.StringWidth(warning) - hudGap - w return line + strings.Repeat(" ", max(1, pad)) + warned + strings.Repeat(" ", hudGap) + drawn + " " diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 2d31de335..45fdd34a4 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1313,8 +1313,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // ago, through the line below. if a.homeGesture(msg) { a.input.reset() - a.noticeEvent(eventHomeGesture) - return tea.Batch(a.edited(), a.openHome()) + return tea.Batch(a.edited(), a.homeByTwoSpaces()) } if text := msg.Key().Text; text != "" { // The ordinary case: a key that carries text types it. diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 8d04b3bdb..2ea5466e2 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -899,16 +899,12 @@ func (b *noticeBoard) take(slot noticeSlot, id string, live bool, now time.Time, wrote = b.settle(slot, now, limitOf) b.current[slot] = id if slot == slotHint { - // THE CONVERSATION'S ROW COUNTS ON ARRIVAL, ONCE PER SESSION. Its tip - // comes and goes with the quiet clock ([app.chatTip]), so there is no - // one departure to measure — and an hour in the slot is one showing, - // not one per event. + // THE CONVERSATION'S ROW IS NOT COUNTED HERE. Its tip waits out the + // quiet clock before it is drawn, so a tip taken by the slot may never + // be seen at all; it is counted when the keys row draws it + // ([app.chatTipCount]). b.lastHintTurn = turn - if id == "" || b.shown[id] { - return true, wrote - } - b.shown[id] = true - return true, b.count(id, limitOf(id)) || wrote + return true, wrote } if id == "" || !live { return true, wrote @@ -932,6 +928,17 @@ func (b *noticeBoard) visible(slot noticeSlot, now time.Time) { } } +// seenOnce counts id as shown on the conversation's row, ONCE PER SESSION +// however many times the tip comes and goes with the quiet clock, and reports +// whether the ledger changed. +func (b *noticeBoard) seenOnce(id string, limit int) bool { + if id == "" || b.shown[id] { + return false + } + b.shown[id] = true + return b.count(id, limit) +} + // settle ends the standing of the tip in a slot, counting a showing when it // stood [noticeReadTime] or more, and retiring the notice when that showing // was its last allowed. It reports whether the ledger changed. A slot with no @@ -1308,6 +1315,20 @@ func (a *app) chatTipPress(x, y int) bool { return true } +// chatTipCount counts the tip the keys row last drew ([app.chatTipDrawn]) as +// shown, once per session ([noticeBoard.seenOnce]). It is asked after every +// message (app.go's [app.Update]) rather than from the draw, because a draw +// writes nothing to disk. +func (a *app) chatTipCount() { + b := &a.notices + if b.seen == nil || a.chatTipDrawn == "" { + return + } + if b.seenOnce(a.chatTipDrawn, a.noticeLimit(a.chatTipDrawn)) { + b.save() + } +} + // liftChatTipCross is the conversation going out of sight — a place or home // opening over it, or another conversation taken up — which is the only thing // that lifts its cross, as leaving home is the only thing that lifts home's diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index 7659cb6b9..80a9e83ad 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -133,13 +133,20 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { // The task tip is the one a first exchange arms highest (notice.go's // table); `/ shows every command` stood here until both feet said it. const hint = "task-in-chat" - // A SHOWING ON THE CONVERSATION'S ROW IS THE SLOT TAKING THE TIP, counted - // once per session however many events re-decide it — so each launch is one - // turn ending, and the ledger on disk has one more showing after it. + // A SHOWING ON THE CONVERSATION'S ROW IS THE KEYS ROW DRAWING THE TIP, + // counted once per session however many events re-decide it — so each + // launch is one turn ending and one frame, and the ledger on disk has one + // more showing after it. + drawn := func(a *app) { + frame(a) + drive(t, a, chatTipDueMsg{}) + } launch := func() *app { a := noticeApp(t, "") + a.width = 160 a.turn = 1 a.noticeEvent(eventTurnEnded) + drawn(a) return a } for session := 1; session <= noticeShownDefault; session++ { @@ -149,6 +156,7 @@ func TestAHintAgesOutAcrossOrdinaryLaunches(t *testing.T) { } // A second event in the same session counts nothing more. a.noticeEvent(eventTurnEnded) + drawn(a) if got := loadNoticeLedger(noticeLedgerPath("")).shown(hint); got != session { t.Fatalf("after launch %d the ledger on disk counts %d showings", session, got) } @@ -988,15 +996,21 @@ func TestTheConversationsSlotChangesHandsSlowly(t *testing.T) { } } -// A TIP IS COUNTED ONCE PER SESSION ON THE CONVERSATION'S ROW, however many -// events re-decide the slot, and its last allowed showing retires it for the -// sessions after while leaving it up for this one. +// A TIP IS COUNTED ONCE PER SESSION ON THE CONVERSATION'S ROW, when the row +// draws it and never when the slot merely takes it, however many times it is +// drawn; and its last allowed showing retires it for the sessions after while +// leaving it up for this one. func TestTheConversationsRowCountsOnceASession(t *testing.T) { b := freshBoard() limit := fixedLimit(2) b.take(slotHint, "tip", false, time.Time{}, limit, 1) + if got := b.ledger.shown("tip"); got != 0 { + t.Fatalf("the slot taking a tip nobody has seen counted %d showings", got) + } + b.seenOnce("tip", 2) b.take(slotHint, "", false, time.Time{}, limit, 2) b.take(slotHint, "tip", false, time.Time{}, limit, 3) + b.seenOnce("tip", 2) if got := b.ledger.shown("tip"); got != 1 { t.Fatalf("one session counted %d showings", got) } @@ -1008,6 +1022,7 @@ func TestTheConversationsRowCountsOnceASession(t *testing.T) { next := newNoticeBoard("", "", true) next.ledger = b.ledger next.take(slotHint, "tip", false, time.Time{}, limit, 1) + next.seenOnce("tip", 2) if !next.retired("tip") { t.Fatal("the last allowed showing did not retire the notice") } diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index 8cb4f4cac..549fa771f 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -446,8 +446,7 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { } if a.placeSpaceArmed { a.placeSpaceArmed = false - a.noticeEvent(eventHomeGesture) - return a.openHome(), true + return a.homeByTwoSpaces(), true } a.placeSpaceArmed = true return nil, true @@ -456,8 +455,17 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } box.reset() + return a.homeByTwoSpaces(), true +} + +// homeByTwoSpaces opens home and THEN says the gesture happened, from a +// conversation's box or a place's. Said first, the event decided home's row +// while the conversation was in front, where home's own tips read as unarmed, +// so the first of them came back fresh and jumped the ring on every trip. +func (a *app) homeByTwoSpaces() tea.Cmd { + cmd := a.openHome() a.noticeEvent(eventHomeGesture) - return a.openHome(), true + return cmd } // placeSend is `alt+enter` over a composer with something in it: THE COMPOSER diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 7bca1b981..563391527 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -642,8 +642,7 @@ var settingUI = map[string]settingMeta{ config.KeyHints: { tab: tabWorkspace, label: "disable hints", widget: widgetToggle, // NO `about` OF ITS OWN: the line under the row is the registry's hint, - // the owner's own wording (2026-09-24), so the panel and `codeaf config` - // say it once ([settingMetaFor]). + // the owner's own wording (2026-09-24), said once ([settingMetaFor]). }, config.KeySplitPct: { tab: tabDisplay, label: "chat width", widget: widgetText, From 9735fefc62e86f11d0769e6fd06fb64f81c1b2d3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:40:12 -0400 Subject: [PATCH 6/6] docs: the hints row lands at the next turn's end everywhere in a running codeaf; noticeLive points at the drawn count Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/hints-and-tips.md | 4 ++-- internal/tui3/notice.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index f4a42e70b..986bb223f 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -239,8 +239,8 @@ Open the settings panel with `/settings` (or `ctrl+,`), go to the **Workspace** the **disable hints** row on. The line under the row reads `disable💡 tips everywhere (requires restart)`. Enter or space toggles it; it is off by default, which means the tips show. (Until 2026-09-22 it was a **hints** row on the Display -tab, on by default.) The change lands at the end of the next turn in a conversation that is -already open, and from the start in one opened after — restarting codeaf is the sure way. On silences the tips — over a conversation's +tab, on by default.) Inside a running codeaf the change lands at the end of the next turn, +in every conversation and on home alike; restarting codeaf is the only way to have it at once. On silences the tips — over a conversation's box and over home's alike — and the what's-new lines together; it does not touch the keys row's own words for a live state — `esc interrupt` and the rest are not hints and cannot be turned off. diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 2ea5466e2..8e02681fc 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -1053,8 +1053,8 @@ func (a *app) noticeFill(slot noticeSlot) bool { // exists to tell apart. // // THE CONVERSATION'S ROW IS NEVER LIVE IN THIS SENSE, because it does not -// measure a standing at all: its showing is counted when the slot takes a tip, -// once per session ([noticeBoard.take]). +// measure a standing at all: its showing is counted when the keys row draws +// the tip, once per session ([app.chatTipCount], [noticeBoard.seenOnce]). func (a *app) noticeLive(slot noticeSlot) bool { if a.notices.hidden[slot] { return false