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..7b9b68748 --- /dev/null +++ b/docs/changes/unreleased/1487-space-space-home-beside-the-tip.md @@ -0,0 +1,14 @@ +--- +kind: changed +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 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 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, +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 3b2620fa0..986bb223f 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -6,32 +6,53 @@ 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. +**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**, 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`. + +**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-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 +**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. @@ -65,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 @@ -99,7 +121,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. @@ -120,6 +142,10 @@ 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. 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 @@ -210,12 +236,14 @@ 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.) 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. 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/manual/chat/home.md b/internal/manual/chat/home.md index 3ed79f62a..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,6 +1475,11 @@ 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.** 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 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..68ac57fe4 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. 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..c34eddb1f 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2311,6 +2311,16 @@ 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). + // 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 // columns on that same line β€” the draft's twins of [app.seamEffortSpan] and // [app.seamApprovalSpan] (boxseam.go), recorded on the same bargain. @@ -3186,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. @@ -3318,6 +3332,14 @@ 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) + } + 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). @@ -3403,8 +3425,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 +3572,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 +3700,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 +3907,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 +4100,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 +5866,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 +7531,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 0a9794a71..110f8aec5 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -15,20 +15,20 @@ 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. -// 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) } @@ -45,63 +45,305 @@ 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 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) + 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") + } + // 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") + } + 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) + } + advance(chatTipQuiet - time.Second) + if got := a.chatTip(); got != "" { + t.Fatalf("the tip drew a second before the wait was up: %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") + } +} + +// 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. +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") + } +} + +// 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) } } @@ -116,8 +358,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..651f03d4e 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, a.chatTipDrawn = hudSpan{}, "" hint := a.footHint(width) right, rightPlain := "", "" if !a.seamShowing() { @@ -289,6 +296,19 @@ 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} + 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 + " " + } + 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 27272448f..e108cb67a 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) { @@ -343,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/input.go b/internal/tui3/input.go index ce7d70a19..45fdd34a4 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1313,7 +1313,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // ago, through the line below. if a.homeGesture(msg) { a.input.reset() - 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 e538a5613..8e02681fc 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 @@ -188,6 +190,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 +209,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 +306,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, 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 @@ -306,11 +319,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 @@ -388,6 +402,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 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 + // 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, @@ -649,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 @@ -859,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) { @@ -870,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 - // 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. + // 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 @@ -903,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 @@ -1009,7 +1045,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 @@ -1017,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 @@ -1128,9 +1164,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 @@ -1166,16 +1202,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 @@ -1189,6 +1218,123 @@ 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 +} + +// 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 +// ([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 6d31d6925..80a9e83ad 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 ─────────────────────────────────────────────────────────────── @@ -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) } @@ -524,8 +532,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 +548,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))) } @@ -688,6 +693,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 { @@ -982,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) } @@ -1002,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/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/placekeys.go b/internal/tui3/placekeys.go index 7aac05bea..549fa771f 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -446,7 +446,7 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { } if a.placeSpaceArmed { a.placeSpaceArmed = false - return a.openHome(), true + return a.homeByTwoSpaces(), true } a.placeSpaceArmed = true return nil, true @@ -455,7 +455,17 @@ func (a *app) placeHomeGesture(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } box.reset() - return a.openHome(), true + 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 cmd } // placeSend is `alt+enter` over a composer with something in it: THE COMPOSER 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 c64b134dd..54c37994f 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3727,19 +3727,6 @@ 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]). - if tip := a.noticeHint(); tip != "" { - return tip - } return a.idleHint() } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 301868298..563391527 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -641,9 +641,8 @@ 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), said once ([settingMetaFor]). }, config.KeySplitPct: { tab: tabDisplay, label: "chat width", widget: widgetText, 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