From f5c0e323e4d0427d67002ee76dab02d49fadbc58 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:24:20 -0400 Subject: [PATCH 1/4] =?UTF-8?q?tui3:=20the=20draft=20kill=20ring=20?= =?UTF-8?q?=E2=80=94=20whole-box=20clears=20keep=20the=20words,=20and=20th?= =?UTF-8?q?e=20=E2=86=91=20walk=20visits=20them=20dim=20in=20front=20of=20?= =?UTF-8?q?the=20sent=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui3/app.go | 16 +++ internal/tui3/detach.go | 7 + internal/tui3/draftring.go | 92 ++++++++++++ internal/tui3/draftring_test.go | 247 ++++++++++++++++++++++++++++++++ internal/tui3/input.go | 21 ++- internal/tui3/pastechip.go | 2 +- internal/tui3/recall.go | 62 ++++++-- internal/tui3/welcome.go | 2 +- 8 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 internal/tui3/draftring.go create mode 100644 internal/tui3/draftring_test.go diff --git a/internal/tui3/app.go b/internal/tui3/app.go index bc7c3d4991..d27b131113 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1975,6 +1975,10 @@ type app struct { // Nil history is a surface with no ↑, which is what --no-history is. history History hist recall + // drafts is the kill ring: the sentences the whole-box clears took, newest + // first (draftring.go). The same ↑ walk visits them in front of the sent + // history, drawn dim, and /drafts lists them. + drafts draftRing // draftFile is where the unsent sentence is kept between sessions // (draft.go); empty means it is not kept at all. @@ -2565,6 +2569,18 @@ type app struct { resume func(file string) (Agent, error) } +// noteKilled is the door every whole-box clear goes through BEFORE the words +// go: the sentence in the box, if there is one, goes on the kill ring +// (draftring.go), so a clear that was a mistake is one ↑ away rather than +// retyped. Clears that hand the words on — a send, the rewind's stash — are +// not kills and do not call here. A conversation switch DOES (detach.go's +// [app.clearConversation]): the sidecar that picks the sentence up answers +// only while that conversation is still kept, and the ring is what still has +// the words if it is let go first. +func (a *app) noteKilled() { + a.drafts.push(a.input.value) +} + // landingKeysWord is the opening line of every session: the keys the status // line has no room for. It is named because the note that writes it also names // the chords inside it for THE PAYLOAD RULE (payload.go), and a sentence diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 2efe8941f9..915cc67233 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -406,6 +406,13 @@ func (a *app) clearConversation() { // The box goes with the conversation it was typed at: the sidecar is holding // it, and the arriving conversation has its own. // + // AND ON THE KILL RING FIRST (draftring.go). The sidecar's answer to where + // the sentence is lasts only as long as the conversation is kept — a tab + // closed, a conversation evicted — and the ring is the net that still has + // the words after that. The push dedupes, so an ordinary switch away and + // back does not fill the walk with copies of one sentence. + a.noteKilled() + // // ALL OF THE BOXES, which is what [app.forgetComposers] adds (recipient.go): // the compact pastes the tokens in the sentence stood for, and every task // page's own unsent line. A stash carried across would be words addressed to diff --git a/internal/tui3/draftring.go b/internal/tui3/draftring.go new file mode 100644 index 0000000000..5809ce7832 --- /dev/null +++ b/internal/tui3/draftring.go @@ -0,0 +1,92 @@ +package tui3 + +import ( + "strings" + "time" +) + +// THE KILL RING: the last few sentences the box LOST, newest first. +// +// ↑ walks what you SENT (recall.go); until now nothing anywhere kept what you +// only TYPED. A ctrl+u that went one word too far, a conversation switched +// away from and closed before its half-written sentence ever came back — all +// of them were gone, and the only way back was to type the words again. So +// every whole-box clear goes through [app.noteKilled] BEFORE the words go, and +// the ring is where they land: the same ↑ walk visits them in front of the +// sent history, drawn dim because they were never sent, and /drafts lists +// them. +// +// TEN, because the ring's job is the last mistake, not a journal. A person +// reaching for it wants the sentence they lost a moment ago, and a hundred +// entries would be a second history with none of history's bookkeeping — +// the cap is what keeps the walk's first steps being the answer. + +// draftRingSize is how many killed drafts the ring keeps. +const draftRingSize = 10 + +// draftEntry is one killed draft: the words as they stood in the box, and when +// they were lost — the moment is what /drafts reads an age from. +type draftEntry struct { + text []rune + at time.Time +} + +// draftRing is the kill ring itself: entries[0] is the newest loss. +type draftRing struct { + entries []draftEntry +} + +// push records a killed draft. +// +// AN EMPTY OR WHITESPACE-ONLY BOX RECORDS NOTHING, on the draft file's own +// law (draft.go): there are no words to come back to, and an entry that drew +// nothing would be the emptiness law broken from inside. +// +// A REPEAT OF THE NEWEST ENTRY ONLY REFRESHES ITS MOMENT. The words are copied +// because the box rewrites its own rune slice in place, and the dedupe is +// because a switch away and back stashes and restores the same sentence: each +// pass would otherwise be one more identical entry, and three switches would +// fill the walk with copies of one sentence. +func (r *draftRing) push(t []rune) { + if strings.TrimSpace(string(t)) == "" { + return + } + if len(r.entries) > 0 && string(r.entries[0].text) == string(t) { + r.entries[0].at = time.Now() + return + } + r.entries = append([]draftEntry{{text: append([]rune(nil), t...), at: time.Now()}}, r.entries...) + if len(r.entries) > draftRingSize { + r.entries = r.entries[:draftRingSize] + } +} + +// drop removes the entry at a newest-first position — the walk's own index, so +// a consumed draft comes out by the same number it was browsed by. +func (r *draftRing) drop(i int) { + if i < 0 || i >= len(r.entries) { + return + } + r.entries = append(r.entries[:i], r.entries[i+1:]...) +} + +// list is the ring as a walk or a page reads it, newest first. +func (r *draftRing) list() []draftEntry { + return append([]draftEntry(nil), r.entries...) +} + +// consume forgets the NEWEST entry with these words and reports whether there +// was one. It is how a sent draft leaves the ring: the sentence lives in the +// history now, and a walk that still dimmed it would be calling a sent +// sentence unsent. The match is on the trimmed words, because enter reads the +// box trimmed and the ring keeps it exactly as it stood. +func (r *draftRing) consume(text string) bool { + text = strings.TrimSpace(text) + for i, entry := range r.entries { + if strings.TrimSpace(string(entry.text)) == text { + r.drop(i) + return true + } + } + return false +} diff --git a/internal/tui3/draftring_test.go b/internal/tui3/draftring_test.go new file mode 100644 index 0000000000..80c501c458 --- /dev/null +++ b/internal/tui3/draftring_test.go @@ -0,0 +1,247 @@ +package tui3 + +import ( + "fmt" + "strings" + "testing" + + "github.com/Agent-Field/aforge-v2/internal/history" +) + +// The kill ring: what a whole-box clear keeps, and how the ↑ walk visits it. +// The ring's own arithmetic is proven against a bare draftRing; the doors that +// feed it and the walk that browses it are proven on an app, because that is +// where the mistakes they guard against happen. + +func TestTheRingKeepsTheTenNewestKillsNewestFirst(t *testing.T) { + var ring draftRing + for i := range 12 { + ring.push([]rune(fmt.Sprintf("kill %d", i))) + } + got := ring.list() + if len(got) != draftRingSize { + t.Fatalf("twelve kills left %d entries, want the cap of %d", len(got), draftRingSize) + } + if string(got[0].text) != "kill 11" || string(got[9].text) != "kill 2" { + t.Fatalf("the ring reads %q … %q, want the ten newest, newest first", + got[0].text, got[9].text) + } + if got[0].at.IsZero() { + t.Fatal("a kill landed without the moment it happened") + } +} + +func TestABlankKillNeverLandsOnTheRing(t *testing.T) { + var ring draftRing + ring.push(nil) + ring.push([]rune("")) + ring.push([]rune(" \n ")) + if got := ring.list(); len(got) != 0 { + t.Fatalf("blank boxes left %d entries — there were no words to keep", len(got)) + } +} + +func TestTheRingRefreshesRatherThanRepeatsTheNewestKill(t *testing.T) { + var ring draftRing + ring.push([]rune("same words")) + ring.push([]rune("other words")) + ring.push([]rune("same words")) // not the newest: a loss of its own + ring.push([]rune("same words")) // the newest again: a refresh, not a copy + if got := ring.list(); len(got) != 3 { + t.Fatalf("a repeated kill grew the ring to %d entries", len(got)) + } +} + +func TestARingDropTakesTheNewestFirstPosition(t *testing.T) { + var ring draftRing + ring.push([]rune("one")) + ring.push([]rune("two")) // newest first: two, one + ring.drop(0) + got := ring.list() + if len(got) != 1 || string(got[0].text) != "one" { + t.Fatalf("drop(0) left %+v, want only the older kill", got) + } + // Off the ends is a no-op: a drop answers a position somebody browsed to, + // and a position that does not exist drops nothing. + ring.drop(-1) + ring.drop(5) + if got := ring.list(); len(got) != 1 { + t.Fatalf("an out-of-range drop lost entries: %d left", len(got)) + } +} + +func TestAConsumeForgetsTheNewestMatchingWords(t *testing.T) { + var ring draftRing + ring.push([]rune("a")) + ring.push([]rune("b")) + ring.push([]rune("a")) // a second, newer loss of the same words + if !ring.consume("a") { + t.Fatal("a sent draft was not consumed") + } + got := ring.list() + if len(got) != 2 || string(got[0].text) != "b" || string(got[1].text) != "a" { + t.Fatalf("the consume took the wrong entry: %+v", got) + } + if !ring.consume(" a ") || ring.consume("a") { + // The match is on the trimmed words, and only the one newest copy goes. + t.Fatal("the consume did not trim, or took more than one copy") + } + if got := ring.list(); len(got) != 1 || string(got[0].text) != "b" { + t.Fatalf("the consumes left %+v", got) + } +} + +// boxText is the draft box as painted rows, ANSI and all: dim is the walk's +// one signal, and it lives in the paint rather than in the words. +func boxText(a *app) string { + block, _, _ := a.inputBlock(a.width) + return strings.Join(block, "\n") +} + +func TestAWholeBoxFlushLandsOnTheRing(t *testing.T) { + a, _ := recallApp(t) + typeInto(t, a, "half a sentence") + drive(t, a, key("ctrl+u")) + if got := a.input.String(); got != "" { + t.Fatalf("ctrl+u left %q in the box", got) + } + entries := a.drafts.list() + if len(entries) != 1 || string(entries[0].text) != "half a sentence" { + t.Fatalf("the flush landed on the ring as %+v", entries) + } + + // A kill that takes only the head of a line leaves the rest of the draft + // standing, and a draft still standing is nothing the ring is about. + typeInto(t, a, "hello") + drive(t, a, key("left"), key("left")) + drive(t, a, key("ctrl+u")) + if got := a.input.String(); got != "lo" { + t.Fatalf("a line-head kill left %+v", got) + } + if got := len(a.drafts.list()); got != 1 { + t.Fatalf("a partial kill grew the ring to %d entries", got) + } + + // And one line of two is the same gesture, not a flush. + a, _ = recallApp(t) + typeInto(t, a, "one") + drive(t, a, keyed("alt+enter")) + typeInto(t, a, "two") + drive(t, a, key("ctrl+u")) + if got := a.input.String(); got != "one\n" { + t.Fatalf("ctrl+u on the second line left %q", got) + } + if got := len(a.drafts.list()); got != 0 { + t.Fatalf("a line kill on a multi-line draft grew the ring to %d entries", got) + } +} + +func TestTheWalkVisitsKilledDraftsDimInFrontOfSentHistory(t *testing.T) { + a, _ := recallApp(t, + history.Entry{Text: "somewhere else", Cwd: "/tmp/other"}, + history.Entry{Text: "older here", Cwd: "/tmp/lab"}, + history.Entry{Text: "newer here", Cwd: "/tmp/lab"}, + ) + typeInto(t, a, "half a sentence") + drive(t, a, key("ctrl+u")) // the kill: the words go on the ring + typeInto(t, a, "a live draft") + + // The killed sentence leads the walk, in front of the newest SENT line. + for _, want := range []string{"half a sentence", "newer here", "older here", "somewhere else"} { + drive(t, a, key("up")) + if a.input.String() != want { + t.Fatalf("the walk shows %q, want %q", a.input.String(), want) + } + if want == "half a sentence" && !strings.Contains(boxText(a), a.pal.dim(want)) { + t.Fatalf("a killed draft did not draw dim:\n%s", boxText(a)) + } + if want == "newer here" && !strings.Contains(boxText(a), a.pal.ink(want)) { + t.Fatalf("a sent line drew as anything but the body's ink:\n%s", boxText(a)) + } + } + + // Back down past the newest entry is the end of the walk, and the live + // draft is exactly where it was — the kill's entry stays on the ring. + drive(t, a, key("down"), key("down"), key("down")) + if a.input.String() != "half a sentence" { + t.Fatalf("walking back shows %q", a.input.String()) + } + drive(t, a, key("down")) + if a.input.String() != "a live draft" || a.recalling() { + t.Fatalf("the walk did not end on the live draft: %q", a.input.String()) + } + if got := len(a.drafts.list()); got != 1 { + t.Fatalf("browsing consumed the entry: %d left", got) + } +} + +func TestSendingAKilledDraftConsumesIt(t *testing.T) { + a, store := recallApp(t) + // The first conversation's greeting owns ↑/↓ over an empty box + // (welcome.go's [app.welcomeStarterKey]) — the walk's own reach, unchanged + // by the ring — so it is put away the way a first send would put it. + a.dismissWelcome() + typeInto(t, a, "the words I lost") + drive(t, a, key("ctrl+u")) + drive(t, a, key("up")) + if a.input.String() != "the words I lost" { + t.Fatalf("the walk did not open on the killed draft: %q", a.input.String()) + } + drive(t, a, key("enter")) + if got := len(a.drafts.list()); got != 0 { + t.Fatalf("a sent draft stayed on the ring: %d entries left", got) + } + if got := store.RecentFor("/tmp/lab", 5); len(got) != 1 || got[0].Text != "the words I lost" { + t.Fatalf("the history holds %+v", got) + } + + // And the next walk finds the sentence once, from the history, in the + // body's own ink — a line that went is not drawn like one that did not. + drive(t, a, key("up")) + if a.input.String() != "the words I lost" { + t.Fatalf("the walk lost the sent line: %q", a.input.String()) + } + if strings.Contains(boxText(a), a.pal.dim("the words I lost")) { + t.Fatalf("a sent line drew dim:\n%s", boxText(a)) + } +} + +func TestEscOnAKilledDraftLeavesItOnTheRing(t *testing.T) { + a, _ := recallApp(t) + typeInto(t, a, "killed words") + drive(t, a, key("ctrl+u")) + typeInto(t, a, "mine") + drive(t, a, key("up")) + drive(t, a, key("esc")) + if a.input.String() != "mine" || a.recalling() { + t.Fatalf("esc left %q (recalling=%v)", a.input.String(), a.recalling()) + } + if got := len(a.drafts.list()); got != 1 { + t.Fatalf("esc consumed the entry: %d left", got) + } + drive(t, a, key("up")) + if a.input.String() != "killed words" { + t.Fatalf("the next walk did not lead with the killed draft: %q", a.input.String()) + } +} + +func TestASwitchRingsTheSentenceItPutsDown(t *testing.T) { + a, _ := recallApp(t) + typeInto(t, a, "half a sentence") + side := a.detachConversation() + if side.draft != "half a sentence" { + t.Fatalf("the sidecar kept %q", side.draft) + } + entries := a.drafts.list() + if len(entries) != 1 || string(entries[0].text) != "half a sentence" { + t.Fatalf("the switch rang %+v", entries) + } + + // Away and back is one entry, not two: the ring refreshes a repeat of the + // newest kill rather than filling the walk with copies of one sentence. + a.input.setText(side.draft) + _ = a.detachConversation() + if got := len(a.drafts.list()); got != 1 { + t.Fatalf("the same sentence switched twice is %d entries", got) + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 88fb25e3bb..bab4e86cba 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1036,6 +1036,17 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // terminal that reports the super modifier at all (kitty's protocol, // win32-input) — everywhere else it is simply never sent, which costs // nothing and is why it is bound rather than detected. + // + // A FLUSH OF THE WHOLE BOX GOES ON THE KILL RING FIRST (draftring.go): + // caret at the end of a one-line draft is the one shape of this key + // after which nothing is left, and nothing-left is the mistake the + // ring exists for. A kill that takes only the head of one line leaves + // the rest of the draft standing — and a walk's box holds a RECALLED + // line, not a draft, so killing it kills nothing that is not already + // kept. + if !a.recalling() && a.input.lineStart() == 0 && a.input.cursor == len(a.input.value) { + a.noteKilled() + } from, to := a.input.lineStart(), a.input.cursor a.input.killToStart() a.editTags(from, to, 0) @@ -1653,10 +1664,14 @@ func (a *app) inputHeight() int { // because a lead the layout drew and the caret arithmetic did not know about // would put the terminal's cursor several cells left of the letter it is on. func draftBlock(e *editor, pal palette, width, maxRows int, hint, lead string) ([]string, int, int) { - return draftBlockWithTags(e, pal, width, maxRows, hint, lead, nil) + return draftBlockWithTags(e, pal, width, maxRows, hint, lead, nil, pal.ink) } -func draftBlockWithTags(e *editor, pal palette, width, maxRows int, hint, lead string, demoted []segment) ([]string, int, int) { +// ink is the paint the words get: pal.ink everywhere but the main draft while +// the recall walk sits on a killed, never-sent entry (recall.go's +// [app.draftInk]) — a dim box is how the walk says these words were never +// sent. +func draftBlockWithTags(e *editor, pal palette, width, maxRows int, hint, lead string, demoted []segment, ink func(string) string) ([]string, int, int) { head := ansi.StringWidth(lead) + ansi.StringWidth(prompt) room := width - head if room < 4 { @@ -1717,7 +1732,7 @@ func draftBlockWithTags(e *editor, pal palette, width, maxRows int, hint, lead s at := segments[i].from boundary := at == 0 || e.value[at-1] == ' ' || e.value[at-1] == '\n' row := string(e.value[at:segments[i].to]) - painted := paintDraftCommands(row, pal, pal.ink, at, boundary, demoted) + painted := paintDraftCommands(row, pal, ink, at, boundary, demoted) // AND A SELECTED RUN WEARS THE MARK, over whatever paint the row already // carries — the same step the transcript's own sweep lights its cells // with, so one gesture has one look wherever it is made (editselect.go). diff --git a/internal/tui3/pastechip.go b/internal/tui3/pastechip.go index aabb60c7da..39301e347f 100644 --- a/internal/tui3/pastechip.go +++ b/internal/tui3/pastechip.go @@ -161,7 +161,7 @@ func (a *app) removePaste(s segment, held *pasteChip) { } func (a *app) pasteDraftBlock(width, rows int) ([]string, int, int) { - block, x, y := draftBlockWithTags(&a.input, a.pal, width, rows, "", a.roomLead(width), a.input.demotedTags) + block, x, y := draftBlockWithTags(&a.input, a.pal, width, rows, "", a.roomLead(width), a.input.demotedTags, a.draftInk()) for i, line := range block { plainLine := ansi.Strip(line) for _, held := range a.pastes { diff --git a/internal/tui3/recall.go b/internal/tui3/recall.go index 839aaf4230..7c8516f18d 100644 --- a/internal/tui3/recall.go +++ b/internal/tui3/recall.go @@ -36,6 +36,13 @@ type History interface { type recall struct { active bool list []string + // unsent is how many of the list's LEADING entries are killed drafts off + // the ring (draftring.go) rather than sent history. They walk in front of + // the newest sent line, newest first, and the box draws them dim + // ([app.draftInk]) because a sentence that never went is not drawn like + // one that did. Sending one consumes it (remember); esc puts the person's + // own draft back and leaves them on the ring. + unsent int // at indexes list; -1 is the live draft, 0 the newest entry. at int draft []rune @@ -45,19 +52,18 @@ type recall struct { func (a *app) recalling() bool { return a.hist.active } // recallBack is ↑. It reports whether it took the key: a surface with no -// history takes nothing and the arrow keeps its older meanings. +// history AND an empty ring takes nothing and the arrow keeps its older +// meanings. func (a *app) recallBack() bool { - if a.history == nil { - return false - } if !a.hist.active { - list := a.recallList() + list, unsent := a.recallList() if len(list) == 0 { return false } a.hist = recall{ active: true, list: list, + unsent: unsent, at: -1, draft: append([]rune(nil), a.input.value...), cursor: a.input.cursor, @@ -112,12 +118,26 @@ func (a *app) recallCancel() { // that is now in the transcript. func (a *app) endRecall() { a.hist = recall{} } -// recallList is the walk's list: this directory first, then everywhere, with -// duplicates dropped so that a prompt typed in both places is one step and not -// two. -func (a *app) recallList() []string { +// recallList is the walk's list, and how many of its leading entries are +// UNSENT: the killed drafts off the ring first, newest first (draftring.go), +// then this directory's sent lines, then everywhere's, with duplicates dropped +// so that a prompt typed in both places — or a sentence the ring and the +// history both hold — is one step and not two. +func (a *app) recallList() ([]string, int) { seen := map[string]bool{} out := make([]string, 0, recallDepth) + // The ring's entries are NOT deduped against each other: each is a loss of + // its own, and their positions in this prefix are the ring's own + // newest-first indexes, which is what a drop on this list answers to. + for _, entry := range a.drafts.list() { + text := string(entry.text) + out = append(out, text) + seen[text] = true + } + unsent := len(out) + if a.history == nil { + return out, unsent + } add := func(entries []history.Entry) { for _, entry := range entries { if entry.Text == "" || seen[entry.Text] { @@ -129,13 +149,35 @@ func (a *app) recallList() []string { } add(a.history.RecentFor(a.workspace, recallDepth)) add(a.history.Recent(recallDepth)) - return out + return out, unsent +} + +// recallUnsent reports whether the walk is sitting on a killed draft rather +// than on a sent line: the one fact the box's ink is chosen by. +func (a *app) recallUnsent() bool { + return a.hist.active && a.hist.at >= 0 && a.hist.at < a.hist.unsent +} + +// draftInk is the ink the box paints its words with: the body's own, or the +// dim rung while the walk sits on a killed draft — the walk's one signal that +// these words were never sent. +func (a *app) draftInk() func(string) string { + if a.recallUnsent() { + return a.pal.dim + } + return a.pal.ink } // remember records one submitted message. It never blocks — internal/history // queues and drains on its own goroutine — and it is deliberately not called // for slash commands: /quit is not a sentence anybody wants back. +// +// A SENT KILLED DRAFT LEAVES THE RING HERE, by its words rather than by the +// walk's position: a person who switched back to a conversation and sent the +// sentence its sidecar had kept never walked at all, and the ring's copy is +// from that moment a second answer to where the sentence lives. func (a *app) remember(text string) { + a.drafts.consume(text) if a.history == nil { return } diff --git a/internal/tui3/welcome.go b/internal/tui3/welcome.go index ac122bec81..260ca55d1e 100644 --- a/internal/tui3/welcome.go +++ b/internal/tui3/welcome.go @@ -1005,7 +1005,7 @@ func (a *app) welcomeUnit(width int) ([]string, []welcomeMark, int, int) { // nobody had spoken in yet went onto the screen in the clear. block, x, row := a.secretDraftBlock(unit) if block == nil { - block, x, row = draftBlockWithTags(&a.input, pal, unit, box, "", a.roomLead(unit), a.input.demotedTags) + block, x, row = draftBlockWithTags(&a.input, pal, unit, box, "", a.roomLead(unit), a.input.demotedTags, a.draftInk()) } caretX, caretRow = lead+x, len(rows)+row for _, line := range block { From 5c2e2af0259094933fa09d20c9b8d2ee05d51a66 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:37:02 -0400 Subject: [PATCH 2/4] tui3: /drafts, the page over the kill ring (enter restores, d lets one go) --- docs/changes/unreleased/1016-draft-page.md | 10 + internal/manual/chat/starting-aforge.md | 5 + internal/tui2/tokens/glyph.go | 11 + internal/tui2/tokens/glyphset.go | 4 + internal/tui2/tokens/nerdfont.go | 10 + .../tokens/testdata/nerdfont_glyphnames.json | 3 + internal/tui3/app.go | 11 + internal/tui3/commands.go | 1 + internal/tui3/detach.go | 1 + internal/tui3/draftpage.go | 257 ++++++++++++++++++ internal/tui3/draftpage_test.go | 38 +++ internal/tui3/input.go | 15 +- 12 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 docs/changes/unreleased/1016-draft-page.md create mode 100644 internal/tui3/draftpage.go create mode 100644 internal/tui3/draftpage_test.go diff --git a/docs/changes/unreleased/1016-draft-page.md b/docs/changes/unreleased/1016-draft-page.md new file mode 100644 index 0000000000..dfd1ef2c5f --- /dev/null +++ b/docs/changes/unreleased/1016-draft-page.md @@ -0,0 +1,10 @@ +--- +kind: added +title: /drafts, the page over the kill ring +surface: [chat] +pr: 1016 +--- + +Cleared drafts are ringed (the walk above in `/drafts` reads them); this page +is the browse door: enter puts one back, d lets one go, all in newest-first +order. diff --git a/internal/manual/chat/starting-aforge.md b/internal/manual/chat/starting-aforge.md index a4a5740ae1..28d2da4c15 100644 --- a/internal/manual/chat/starting-aforge.md +++ b/internal/manual/chat/starting-aforge.md @@ -702,3 +702,8 @@ do?", "what does ctrl+b do?", "can you read a PDF?" and "why did you just ask me that?" are all fair questions to type straight into the conversation. If the manual has nothing on something, that usually means aforge does not do it, and it will tell you so instead of inventing an answer. + +## /drafts and the ↑ walk — cleared drafts come back too + +`ctrl+u`, a conversation switch, any clear that empties the whole box — aforge pushes the draft onto a ring of ten (the kill ring, `draftring.go`). The usual `↑` walk, which used to answer about the sent lines, now visits the ring first, dim in front of the sent history. `/drafts` opens the same ring as its own page: a list with `enter` restorer over what is left in the box (that box, too, joins the ring before the restored line takes it), and `d` letting one go for good. A cleared draft is never lost and never keeps its place in the ring once it lands back in the box. + diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 4a777eb38a..4fc7f7ef11 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -163,6 +163,16 @@ const ( // exchange unreadable at exactly the moment it matters. U+21B3 is // East_Asian_Width=Neutral and one cell under both shipping rulers. GlyphReplyIn = "↳" + // GlyphDraftUnsent is a message a person typed and then CLEARED the whole + // box into — the mark the /drafts page draws in front of each line that + // is still waiting to come back (internal/tui3's draftpage.go). + // + // IT IS PENCIL-SHAPED LIKE [GlyphWrite] AND NOT THE SAME PENCIL. ✎ is the + // step gutter's "a call wrote something down"; ✐ (U+2710 LOWER RIGHT PENCIL) + // is a thing the hand still holds. It is also deliberately NOT the steer + // prompt's mapped-into arrow one slot up: a draft has gone nowhere yet, it + // sits on the wrong side of the line that one draws. + GlyphDraftUnsent = "✐" // The execution voices (5.5). A work record is four speakers and no // labels: the model thinking, the tools it reached for, the reader @@ -445,6 +455,7 @@ func Glyphs() []GlyphInfo { {"PromptChat", GlyphPromptChat, '›', false}, {"PromptSteer", GlyphPromptSteer, '↦', false}, {"ReplyIn", GlyphReplyIn, '↳', false}, + {"DraftUnsent", GlyphDraftUnsent, '✐', false}, {"Thought", GlyphThought, '✳', false}, {"Shell", GlyphShell, '$', false}, {"Search", GlyphSearch, '⌕', false}, diff --git a/internal/tui2/tokens/glyphset.go b/internal/tui2/tokens/glyphset.go index a352aae05f..f4374be4e0 100644 --- a/internal/tui2/tokens/glyphset.go +++ b/internal/tui2/tokens/glyphset.go @@ -181,6 +181,10 @@ const ( // hears `?` for a search, and `?` is already the mark for work waiting on a // person, which is a row the filter box is very often sitting above. GFilter + // GDraftUnsent is the mark of a message typed and then cleared before it + // was sent: the ring of them behind /drafts and the ↑ walk's dim end + // (internal/tui3's draftring.go, draftpage.go). + GDraftUnsent glyphIDCount ) diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index 12af9b2fad..2926cd3354 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -299,6 +299,16 @@ var vocabulary = []GlyphBinding{ ID: GReplyIn, Name: "ReplyIn", Meaning: "the answer to a question put back to the asker, drawn under it", Plain: GlyphReplyIn, UsualTint: TextTertiary, Geometry: true, }, + // A composer's own mark that is NOT geometry: the unsent draft (a message + // cleared before sending). Both sides are "being written", the outline + // pencil on the floor and the compose icon where a patched font supplies + // one — the same shape at two weights, which is the whole test of a binding. + { + ID: GDraftUnsent, Name: "DraftUnsent", Meaning: "a message typed and cleared before it was sent", + Plain: GlyphDraftUnsent, NerdFont: "\uF044", NFName: "nf-fa-pencil_square_o", + ASCII: "w", + UsualTint: TextTertiary, NFAmbiguous: true, AutoUpgrade: true, + }, // -- the execution voices (5.5) ------------------------------------------ { diff --git a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json index d969efaddc..3309b8d964 100644 --- a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json +++ b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json @@ -163,5 +163,8 @@ }, "nf-pl-right_soft_divider": { "code": "e0b3" + }, + "nf-fa-pencil_square_o": { + "code": "f044" } } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d27b131113..415ba8fada 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2323,6 +2323,10 @@ type app struct { // anything; closed, it costs the frame nothing. permPanel permPanel + // draftPage is the list /drafts opens over the ring of cleared-but-kept + // drafts (draftring.go): closed, it costs the frame nothing. + draftPage draftPanel + // orders is the standing place's state: the shelves of what stands here — this // conversation's orders, this project's and the machine's (place_standing.go). // It reads the engine's own seam, so a surface whose agent has no ambient @@ -6910,6 +6914,13 @@ func (a *app) slash(line string) tea.Cmd { a.openPermissions() return nil + case "drafts": + // The ring of cleared-but-kept drafts, as a list (draftpage.go). No + // argument form: the rows are a person's own words, and naming them is + // reading this list first. + a.openDrafts() + return nil + case "standing": // WHAT IS ALREADY TRUE HERE, as a list, with the three keys that take one // back on it (standingpage.go). Nothing on that page is NAMED at the command diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index b132789c51..80b3d26848 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -82,6 +82,7 @@ var commands = []command{ // on it rather than on "unknown command: /clear". {name: "new", desc: "start another conversation in this project", alias: []string{"clear", "clean", "reset"}}, {name: "resume", desc: "open an earlier conversation", alias: []string{"sessions"}}, + {name: "drafts", desc: "cleared-but-kept drafts · enter restores one, d lets one go"}, {name: "compact", desc: "summarize the conversation now"}, {name: "stop", desc: "stop the open task or selected work · asks first"}, {name: "autonomy", desc: "how questions are handled while you are away"}, diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 915cc67233..c7e811abd2 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -454,6 +454,7 @@ func (a *app) closeForSwitch() { // about to start work in a conversation they are no longer in // (subharness.go). a.subPage.close() + a.draftPage.close() a.roster.close() a.shelf.close() a.closeLists() diff --git a/internal/tui3/draftpage.go b/internal/tui3/draftpage.go new file mode 100644 index 0000000000..5212d4db05 --- /dev/null +++ b/internal/tui3/draftpage.go @@ -0,0 +1,257 @@ +package tui3 + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// THE PAGE OVER THE KILL RING: /drafts, the box's lost sentences as a list. +// +// ↑ walks what you SENT (recall.go) and visits the ring in front of it, dim; +// this page is the same ring as its own screen — the door for the person who +// knows it was more than a step or two back, or who wants the whole ten to +// choose from rather than walk for the one. What it lists is written by +// [app.noteKilled] at every whole-box clear (draftring.go says what lands and +// what never does), and the two readers share one rule: the list is newest +// first and a restored line LEAVES the ring, because a line back in the box +// is no longer lost. +// +// ENTER RESTORES OVER WHAT IS THERE, AND IT NEVER THROWS AWAY. A box with +// words in it when the page opens is a draft too, so restoring pushes it onto +// the ring before the chosen line takes the box — the act of recovering one +// sentence cannot cost another. That is the ring's own law (push's comment +// says why an empty box records nothing) applied to the one clear this page +// performs itself. + +// draftHeading is the page's one heading line: what is listed, and what enter +// and d do, because this list's two keys are the half of it nobody can guess +// (the hint line says them too; the heading is where a person reading the +// page itself finds them). +const draftHeading = "drafts you cleared · enter puts one back · d lets one go" + +// draftEmptyWord is THE ONE LINE the page draws with nothing in the ring. The +// emptiness law is about facts unknown at draw time; an empty ring is a KNOWN +// fact — the clear a person is looking for never happened — and a page that +// answered /drafts with blank space would read as a page that failed to open +// rather than as a page saying so. +const draftEmptyWord = "no cleared draft is waiting" + +// draftPageRows is the most of the ring the page ever draws under the heading: +// the ring's own cap, so the page never scrolls to reach something the walk +// could reach first. +const draftPageRows = draftRingSize + +// draftPanel is the page's whole state. The zero value is closed. +type draftPanel struct { + open bool + entries []draftEntry + cursor int + top int + // owner maps each screen line back to the entry that drew it, written at + // layout for the pointer — the same bargain the registry panels make + // (connectpanel.go, permissions.go). The heading answers to no entry. + owner []int +} + +func (p *draftPanel) close() { *p = draftPanel{} } + +func (p *draftPanel) start(entries []draftEntry) { + *p = draftPanel{open: true, entries: entries} +} + +// adopt takes a re-read of the same ring under the cursor, after a drop +// shortened it. The cursor holds its PLACE rather than its entry, because the +// entry it was on is the one that just went away and the next thing a person +// wants is whatever moved up into its position. +func (p *draftPanel) adopt(entries []draftEntry) { + p.entries = entries + p.cursor = moveCursor(p.cursor, 0, len(p.entries)) + p.follow(draftPageRows) +} + +func (p *draftPanel) move(delta int) { + p.cursor = moveCursor(p.cursor, delta, len(p.entries)) + p.follow(draftPageRows) +} + +func (p *draftPanel) follow(height int) { + p.top = listTop(p.cursor, p.top, len(p.entries), height) +} + +// label is one entry's own half of its row: the unsent mark and the words as +// they stood. Both are DIM, because they were never sent — the same ink the +// ↑ walk gives them (app.draftInk), so the two doors onto the ring read as +// one thing. +func (p *draftPanel) label(index int, pal palette) string { + if index < 0 || index >= len(p.entries) { + return "" + } + return pal.dim(pal.glyph(tokens.GDraftUnsent) + " " + string(p.entries[index].text)) +} + +// note is the dim tail, and it is the AGE and nothing else: the ring's +// entries are otherwise identical in kind, so the one fact a row can carry +// that another cannot is how long it has been sitting here. +func (p *draftPanel) note(index int) string { + if index < 0 || index >= len(p.entries) { + return "" + } + return ago(p.entries[index].at) +} + +// height is how many lines the page wants: the heading, plus the entries. +// With nothing in the ring it wants the heading and the one dim line, which +// is the emptiness law drawn — never a blank page. +func (p *draftPanel) height(width int) int { + if !p.open { + return 0 + } + if len(p.entries) == 0 { + return 2 + } + return 1 + overlayWindow(width, p.top, len(p.entries), draftPageRows, p.note) +} + +func (p *draftPanel) draw(width, n int, pal palette, hover int) []string { + if n <= 0 || !p.open { + return nil + } + fill := newOverlayFill(width, n, pal, hover) + // THE HEADING IS A [overlayFill.plain] LINE, which is what makes it + // unpressable without anything downstream having to know it is a heading: + // plain records the line as belonging to no entry, and [app.draftPagePress] + // already swallows those (permissions.go made the same bargain first). + fill.plain(pal.dim(fit(" "+draftHeading, width))) + if len(p.entries) == 0 { + fill.plain(pal.dim(fit(" "+draftEmptyWord, width))) + } else { + p.follow(overlayItems(n-1, width)) + for at := p.top; at < len(p.entries) && fill.room(); at++ { + if !fill.add(at, p.label(at, pal), p.note(at), at == p.cursor, false) { + break + } + } + } + lines, owner := fill.done() + // THE BLOCK IS EXACTLY THE HEIGHT IT WAS PROMISED (palette.go's + // [overlayFill.done] says why). The heading is what makes the promise + // breakable here: [draftPanel.height] counted it against the top the last + // frame left behind, and the scroll above may have moved that top since. + for len(lines) < n { + lines = append(lines, "") + owner = append(owner, -1) + } + p.owner = owner + return lines +} + +// ── the app's side ────────────────────────────────────────────────────────── + +// openDrafts is /drafts. +// +// The entries are read HERE and not held from boot, on the terms /permissions +// reads its rows: another clear can have landed since the page was last open, +// and asking costs one slice copy of ten. +func (a *app) openDrafts() { + a.closeLists() + a.dismissWelcome() + a.draftPage.start(a.drafts.list()) + a.touch() +} + +// draftRestore is enter, and the click that means the same thing: the entry +// under the cursor takes the box, and leaves the ring — a line back in the +// box is a line no longer lost. What was in the box goes onto the ring first +// (the page's header says why), unless the box holds exactly the words being +// restored, which is what happens when the ring was browsed from the ↑ walk +// and this is the same sentence coming home. +func (a *app) draftRestore(at int) { + p := &a.draftPage + if at < 0 || at >= len(p.entries) { + return + } + words := string(p.entries[at].text) + live := a.input.String() + a.drafts.drop(at) + if words != live { + a.drafts.push([]rune(live)) + } + p.close() + a.input.setText(words) + a.syncLists() + a.touch() +} + +// draftDrop is d: the entry under the cursor is let go for good. No second +// press, because the line is not thrown away the way a permission line is — +// it stays typed history nowhere, but it was also never anything but a clear +// the person already chose once. +func (a *app) draftDrop(at int) { + p := &a.draftPage + if at < 0 || at >= len(p.entries) { + return + } + a.drafts.drop(at) + p.adopt(a.drafts.list()) + a.touch() +} + +// draftPageKey routes one keypress while the page owns the keyboard. +func (a *app) draftPageKey(msg tea.KeyPressMsg) tea.Cmd { + p := &a.draftPage + switch msg.String() { + case "esc": + p.close() + case "up", "ctrl+p": + p.move(-1) + case "down", "ctrl+n": + p.move(1) + case "pgup": + p.move(-(draftPageRows - 1)) + case "pgdown": + p.move(draftPageRows - 1) + // enter is the list grammar's act and d is the word for it. The letter is + // free here in a way it is not on a filterable list: nothing on this page + // is typed into, so a bare key can mean what it says (permissions.go's + // panel set the precedent and the reason). + case "enter": + a.draftRestore(p.cursor) + case "d": + a.draftDrop(p.cursor) + } + a.touch() + return nil +} + +// draftPagePress resolves a click on one of the page's rows. +func (a *app) draftPagePress(y int) tea.Cmd { + p := &a.draftPage + mark, ok := a.chromeAt(y) + if !ok || mark.kind != chromeOverlay { + // A press anywhere else closes it, which is what pressing outside a + // modal list means everywhere on this surface. + p.close() + a.touch() + return nil + } + at := -1 + if mark.index >= 0 && mark.index < len(p.owner) { + at = p.owner[mark.index] + } + if at < 0 { + // The heading, the empty line, or a blank under the last row: a line + // belonging to no entry. It is swallowed rather than acted on — + // pressing a word must not act on whichever entry it happened to be + // nearest. + return nil + } + // The pointer moves the cursor before it acts, so the entry restored is + // the one the press was aimed at, and the row a person can see under their + // hand is the row enter would have taken. + if at != p.cursor { + p.cursor = at + } + a.draftRestore(at) + return nil +} diff --git a/internal/tui3/draftpage_test.go b/internal/tui3/draftpage_test.go new file mode 100644 index 0000000000..0771162b2f --- /dev/null +++ b/internal/tui3/draftpage_test.go @@ -0,0 +1,38 @@ +package tui3 + +import ( + "testing" +) + +// THE PAGE OVER THE RING: a list a person browses with ↑/↓, with enter to +// restore and d to let one go. A restored line LEAVES the ring, so the walk +// above and the page below see the same lines; the one line the page draws +// when nothing is in the ring is the emptiness law. +func TestTheDraftPageListsNewestFirst(t *testing.T) { + a := &app{draftPage: draftPanel{}} + a.drafts.push([]rune("first")) + a.drafts.push([]rune("second")) + a.openDrafts() + if got := a.drafts.list(); string(got[0].text) != "second" { + t.Fatalf("newest-first order flipped: %q", string(got[0].text)) + } + if len(a.draftPage.entries) != 2 { + t.Fatalf("page read the ring once: got %d entries", len(a.draftPage.entries)) + } +} + +// ENTER RESTORES OVER WHAT IS THERE — a box with words gets pushed BEFORE the +// restored one lands, so recovery never costs the other sentence. +func TestDraftRestorePushesTheLiveBox(t *testing.T) { + a := &app{draftPage: draftPanel{}} + a.drafts.push([]rune("lost words")) + a.input.setText("current") + a.openDrafts() + a.draftRestore(0) + if got := a.input.String(); got != "lost words" { + t.Fatalf("restore put the box elsewhere: %q", got) + } + if list := a.drafts.list(); len(list) != 1 || string(list[0].text) != "current" { + t.Fatalf("the box that was live should have joined the ring: %v", list) + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index bab4e86cba..92ba488764 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -514,11 +514,16 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return a.harnessPanelKey(msg) } - // And the permissions panel, which is those two panels' twin in every - // respect that matters here: opened by a command, nothing being typed under - // it, and esc leaving the conversation exactly as it was (permissions.go). - // Being modal is also what frees a bare d to mean "drop this line" — no - // draft is under this one for a letter to fall through into. + // And the permissions panel, which is those panels' twin (permissions.go): + // opened by a command, nothing typed under it, esc leaves exactly as it was. + // Modal also frees a bare d to mean "drop this line" — no draft is under it + // for the letter to fall into. + // + // The draft ring's page rides the same modal posture (draftpage.go): the + // bare d below it says "let this one go". + if a.draftPage.open && msg.String() != "ctrl+c" { + return a.draftPageKey(msg) + } if a.permPanel.open && msg.String() != "ctrl+c" { return a.permPanelKey(msg) } From db679f795ef1ef3f19a7156760235fd81adda6b9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sat, 12 Sep 2026 16:37:53 -0400 Subject: [PATCH 3/4] changelog: the draft-page entry uses the PR's own number --- .../unreleased/{1016-draft-page.md => 1018-draft-page.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1016-draft-page.md => 1018-draft-page.md} (96%) diff --git a/docs/changes/unreleased/1016-draft-page.md b/docs/changes/unreleased/1018-draft-page.md similarity index 96% rename from docs/changes/unreleased/1016-draft-page.md rename to docs/changes/unreleased/1018-draft-page.md index dfd1ef2c5f..62fdd703cd 100644 --- a/docs/changes/unreleased/1016-draft-page.md +++ b/docs/changes/unreleased/1018-draft-page.md @@ -2,7 +2,7 @@ kind: added title: /drafts, the page over the kill ring surface: [chat] -pr: 1016 +pr: 1018 --- Cleared drafts are ringed (the walk above in `/drafts` reads them); this page From 60c31f0fbb1526c2cc429aa36fc6e2577c7cf7be Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 15:38:09 -0400 Subject: [PATCH 4/4] tui3: /drafts takes its place under /compact and has a fate on home --- internal/tui3/commands.go | 2 +- internal/tui3/homeslash.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 80b3d26848..04ae0fd21e 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -82,8 +82,8 @@ var commands = []command{ // on it rather than on "unknown command: /clear". {name: "new", desc: "start another conversation in this project", alias: []string{"clear", "clean", "reset"}}, {name: "resume", desc: "open an earlier conversation", alias: []string{"sessions"}}, - {name: "drafts", desc: "cleared-but-kept drafts · enter restores one, d lets one go"}, {name: "compact", desc: "summarize the conversation now"}, + {name: "drafts", desc: "cleared-but-kept drafts · enter restores one, d lets one go"}, {name: "stop", desc: "stop the open task or selected work · asks first"}, {name: "autonomy", desc: "how questions are handled while you are away"}, {name: "autonomy", args: " ", desc: "change one project's question rule"}, diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index eb1f459f96..e77b9b994f 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -214,7 +214,7 @@ func homeFate(word, rest string) string { case "land", "workspace": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "autonomy", - "copy", "select", "rewind", "compact", "export": + "copy", "select", "rewind", "compact", "export", "drafts": return fateNeedsChat case "standing": // Bare it is the standing place; with words it is a card raised in a