feat(tui): questions, completion, the task list, and fixes from real runs

- Questions: an open ask is answered one question at a time. A digit
  answers a single choice or toggles a multi choice, typed text joins the
  picks as an answer no option offered, Esc declines with nothing
  selected. The card left in the scrollback says what was answered.
- Completion: `/` at the start of a line and an `@` token open the
  kernel's menu as they are typed, Tab asks anywhere. Offsets cross
  /complete in UTF-16 both ways, so a line with CJK before the token
  splices where the kernel meant; a directory keeps the menu open a level
  down.
- The task list is re-read from /todos when the kernel says it moved and
  drawn while it has work left; a finished list goes away.

Found running against a real model: a thought-only step and whitespace-only
answers printed an empty speaker header; option and menu rows lost their
indent to the row-shortening helper; the receipt read the verdict "done"
as unfinished.
This commit is contained in:
esengine committed 2026-09-25 00:27:00 -07:00
1 parent ed499c4cac
commit 5ff9d7a0f5
8 files changed
+506 -20

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
package tui
import (
"context"
"fmt"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
// askState walks an open question card one question at a time. picks holds
// each question's answer so far: option labels, plus typed text, which the
// kernel takes as an answer no option offered.
type askState struct {
item int
at int
picks [][]string
}
func (m *model) openAsk(it *Item) *askState {
if m.ask == nil || m.ask.item != it.ID {
m.ask = &askState{item: it.ID, picks: make([][]string, len(it.Ask.Questions))}
for i, q := range it.Ask.Questions {
m.ask.picks[i] = slices.Clone(q.Default)
}
}
return m.ask
}
// answerAsk takes a key while a question card is open. A digit picks an
// option: on a single-choice question that answers it, on a multi-choice one
// it toggles. Enter answers with the typed text, or, with nothing typed,
// confirms what is picked. Esc answers with nothing, which is the kernel's
// way of hearing "no answer".
func (m *model) answerAsk(it *Item, k string) (tea.Cmd, bool) {
st := m.openAsk(it)
q := it.Ask.Questions[st.at]
switch {
case k == "esc":
for i := range st.picks {
st.picks[i] = nil
}
return m.sendAsk(it), true
case len(k) == 1 && k[0] >= '1' && k[0] <= '9' && m.composer.Value() == "":
n := int(k[0] - '1')
if n >= len(q.Options) {
return nil, true
}
label := q.Options[n].Label
if !q.Multi {
st.picks[st.at] = []string{label}
return m.nextQuestion(it), true
}
if i := slices.Index(st.picks[st.at], label); i >= 0 {
st.picks[st.at] = slices.Delete(st.picks[st.at], i, i+1)
} else {
st.picks[st.at] = append(st.picks[st.at], label)
}
return nil, true
case k == "enter":
if typed := strings.TrimSpace(m.composer.Value()); typed != "" {
m.composer.Reset()
st.picks[st.at] = append(st.picks[st.at], typed)
}
if len(st.picks[st.at]) == 0 {
return nil, true
}
return m.nextQuestion(it), true
}
return nil, false
}
func (m *model) nextQuestion(it *Item) tea.Cmd {
st := m.ask
if st.at+1 < len(it.Ask.Questions) {
st.at++
return nil
}
return m.sendAsk(it)
}
// sendAsk answers the card. The verdict it seals the card with is what was
// answered, so the line left in the scrollback says it.
func (m *model) sendAsk(it *Item) tea.Cmd {
answers := make([]AskAnswer, len(it.Ask.Questions))
said := make([]string, 0, len(it.Ask.Questions))
for i, q := range it.Ask.Questions {
answers[i] = AskAnswer{QuestionID: q.ID, Selected: m.ask.picks[i]}
if len(m.ask.picks[i]) > 0 {
said = append(said, strings.Join(m.ask.picks[i], ", "))
}
}
verdict := "declined"
if len(said) > 0 {
verdict = strings.Join(said, " · ")
}
m.ask = nil
m.tr.Decide(it.ID, verdict)
id := it.Ask.ID
return tea.Batch(m.commit(), m.call("answer", func(ctx context.Context) error { return m.client.Answer(ctx, id, answers) }))
}
// askCard draws the question in hand, with what is picked so far.
func (m *model) askCard(it *Item) []string {
st := m.openAsk(it)
qs := it.Ask.Questions
if len(qs) == 0 {
return nil
}
q := qs[st.at]
title := "A question for you"
if len(qs) > 1 {
title = fmt.Sprintf("Question %d of %d", st.at+1, len(qs))
}
lines := []string{termrender.Accent(" ◇ ") + termrender.Bold(title), " " + oneLine(q.Prompt, m.width-6)}
for i, o := range q.Options {
mark := " "
if slices.Contains(st.picks[st.at], o.Label) {
mark = termrender.Green("✓ ")
}
line := fmt.Sprintf(" %s%d. %s", mark, i+1, oneLine(o.Label, m.width-12))
if o.Description != "" {
line += termrender.Dim(" — " + oneLine(o.Description, max(m.width-16-termrender.VisibleWidth(o.Label), 10)))
}
lines = append(lines, line)
}
hint := "number to choose · or type an answer and press enter · esc to decline"
if q.Multi {
hint = "numbers to toggle · enter to confirm · type to add your own · esc to decline"
}
return append(lines, termrender.Dim(" "+hint))
}
+161
View File
@@ -0,0 +1,161 @@
package tui
import (
"strings"
"unicode/utf16"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
const menuRows = 8
// menu is the composer's open completion list for the line it was asked
// about; an answer for any other line is stale and dropped.
type menu struct {
line string
c Completion
sel int
}
type completionMsg struct {
line string
c Completion
err error
}
// cursorByte is the composer cursor as a byte offset into its value.
func (m *model) cursorByte() int {
value := m.composer.Value()
lines := strings.Split(value, "\n")
row := min(m.composer.Line(), len(lines)-1)
off := 0
for _, l := range lines[:row] {
off += len(l) + 1
}
cur := []rune(lines[row])
return off + len(string(cur[:min(m.composer.Column(), len(cur))]))
}
// utf16At is the UTF-16 offset of byte offset b in s: the unit /complete
// counts in, since a browser indexes the same string that way.
func utf16At(s string, b int) int {
return len(utf16.Encode([]rune(s[:min(b, len(s))])))
}
// byteAt is the byte offset of UTF-16 offset u in s.
func byteAt(s string, u int) int {
units := 0
for i, r := range s {
if units >= u {
return i
}
units += utf16.RuneLen(r)
}
return len(s)
}
// wantsMenu reports a line whose cursor sits in a token the menu answers
// without being asked: a bare slash command, or an @-reference.
func wantsMenu(line string, cursor int) bool {
if strings.HasPrefix(line, "/") && !strings.ContainsAny(line, " \n") {
return true
}
start := strings.LastIndexAny(line[:cursor], " \n\t") + 1
return strings.HasPrefix(line[start:cursor], "@")
}
func (m *model) fetchCompletion() tea.Cmd {
line := m.composer.Value()
cursor := utf16At(line, m.cursorByte())
return func() tea.Msg {
c, err := m.client.Complete(m.ctx, line, cursor)
return completionMsg{line: line, c: c, err: err}
}
}
// refreshMenu follows the composer after an edit: it asks again where the
// menu answers on its own, and closes the menu anywhere else.
func (m *model) refreshMenu() tea.Cmd {
line := m.composer.Value()
if !wantsMenu(line, m.cursorByte()) {
m.menu = nil
return nil
}
return m.fetchCompletion()
}
func (m *model) onCompletion(msg completionMsg) {
if msg.err != nil || msg.line != m.composer.Value() || len(msg.c.Items) == 0 {
m.menu = nil
return
}
m.menu = &menu{line: msg.line, c: msg.c}
}
// menuKey takes the keys an open menu owns.
func (m *model) menuKey(k string) (tea.Cmd, bool) {
if m.menu == nil {
return nil, false
}
n := len(m.menu.c.Items)
switch k {
case "tab", "down":
m.menu.sel = (m.menu.sel + 1) % n
case "shift+tab", "up":
m.menu.sel = (m.menu.sel + n - 1) % n
case "esc":
m.menu = nil
case "enter":
return m.acceptCompletion(), true
default:
return nil, false
}
return nil, true
}
// acceptCompletion replaces the token the menu answered with the chosen item.
// A directory keeps the menu open one level down.
func (m *model) acceptCompletion() tea.Cmd {
c, item := m.menu.c, m.menu.c.Items[m.menu.sel]
line := m.menu.line
from, to := byteAt(line, c.From), byteAt(line, c.To)
head := line[:from] + item.Insert
m.composer.SetValue(head + line[to:])
if !strings.Contains(line, "\n") {
m.composer.SetCursorColumn(utf8.RuneCountInString(head))
}
m.menu = nil
if item.Descend {
return m.fetchCompletion()
}
return nil
}
func (m *model) menuLines() []string {
if m.menu == nil {
return nil
}
items := m.menu.c.Items
first := max(0, min(m.menu.sel-menuRows/2, len(items)-menuRows))
last := min(len(items), first+menuRows)
lines := make([]string, 0, last-first+1)
for i := first; i < last; i++ {
it := items[i]
label := oneLine(it.Label, m.width/2)
row := " " + label
if i == m.menu.sel {
row = termrender.Accent(" › ") + termrender.Bold(label)
}
if it.Hint != "" {
row += termrender.Dim(" " + oneLine(it.Hint, max(m.width-8-termrender.VisibleWidth(label), 10)))
}
lines = append(lines, row)
}
if len(items) > menuRows {
lines = append(lines, termrender.Dim(" tab/↑↓ to move · enter to choose · esc to close"))
}
return lines
}
+15 -2
View File
@@ -53,13 +53,22 @@ func (p *pasteStore) expand(s string) string {
}
func (m *model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if open := m.tr.OpenPrompt(); open != nil && open.Kind == ItemApproval {
if cmd, handled := m.answerApproval(open, msg.String()); handled {
if open := m.tr.OpenPrompt(); open != nil {
answer := m.answerApproval
if open.Kind == ItemAsk {
answer = m.answerAsk
}
if cmd, handled := answer(open, msg.String()); handled {
return m, cmd
}
}
if cmd, handled := m.menuKey(msg.String()); handled {
return m, cmd
}
empty := m.composer.Value() == ""
switch msg.String() {
case "tab":
return m, m.fetchCompletion()
case "enter":
return m, m.send(false)
case "ctrl+s":
@@ -105,8 +114,12 @@ func (m *model) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
}
}
before := m.composer.Value()
var cmd tea.Cmd
m.composer, cmd = m.composer.Update(msg)
if m.composer.Value() != before {
return m, tea.Batch(cmd, m.refreshMenu())
}
return m, cmd
}
+17 -1
View File
@@ -55,6 +55,9 @@ type model struct {
pastes pasteStore
status Status
quitArmedAt time.Time
ask *askState
menu *menu
todos []TodoItem
}
type (
@@ -161,7 +164,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.u.Event.Kind == "turn_done" {
m.noteTurnEnd()
}
return m, tea.Batch(m.commit(), m.waitUpdate())
cmds := []tea.Cmd{m.commit(), m.waitUpdate()}
if m.tr.TodosMoved {
m.tr.TodosMoved = false
cmds = append(cmds, m.fetchTodos())
}
return m, tea.Batch(cmds...)
case historyMsg:
return m, m.restore(msg)
case statusMsg:
@@ -184,6 +192,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tr.AddNotice("error", "queue: "+msg.err.Error())
}
return m, m.commit()
case todosMsg:
if msg.err == nil {
m.todos = msg.items
}
return m, nil
case completionMsg:
m.onCompletion(msg)
return m, nil
case tea.PasteMsg:
m.composer.InsertString(m.pastes.fold(msg.Content))
return m, nil
+109
View File
@@ -28,6 +28,16 @@ func (k *recordingKernel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
k.calls = append(k.calls, r.Method+" "+r.URL.Path+" "+strings.TrimSpace(string(body)))
k.mu.Unlock()
switch r.URL.Path {
case "/complete":
// "看 @no": the token starts after one CJK rune and a space, two UTF-16 units.
_ = json.NewEncoder(w).Encode(map[string]any{"kind": "ref", "from": 2, "to": 5,
"items": []map[string]any{{"label": "notes.md", "insert": "@notes.md "}, {"label": "notes/", "insert": "@notes/"}}})
case "/todos":
_ = json.NewEncoder(w).Encode([]map[string]any{
{"content": "read the code", "status": "completed"},
{"content": "fix the bug", "status": "in_progress", "activeForm": "fixing the bug"},
{"content": "run tests", "status": "pending"},
})
case "/inbox/items":
_ = json.NewEncoder(w).Encode(map[string]string{"itemId": "q-7"})
default:
@@ -47,6 +57,10 @@ func testModel(t *testing.T) (*model, *recordingKernel) {
srv := httptest.NewServer(k)
t.Cleanup(srv.Close)
m := newModel(context.Background(), Options{Client: &Client{HTTP: srv.Client(), Base: srv.URL}})
// No stream in these tests: a closed channel answers the wait at once.
closed := make(chan Update)
close(closed)
m.updates = closed
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return m, k
}
@@ -218,3 +232,98 @@ func TestCtrlCTwiceQuitsWhenIdle(t *testing.T) {
t.Fatal("second ctrl+c did not quit")
}
}
func askEvent() eventwire.Event {
return eventwire.Event{Kind: "ask_request", Ask: &eventwire.Ask{ID: "ask1", Questions: []eventwire.AskQuestion{
{ID: "q1", Prompt: "Which database?", Options: []eventwire.AskOption{{Label: "Postgres"}, {Label: "SQLite"}}},
{ID: "q2", Prompt: "Which extras?", Multi: true, Options: []eventwire.AskOption{{Label: "cache"}, {Label: "search"}, {Label: "queue"}}},
}}}
}
// A question card is answered one question at a time: a number answers a
// single choice, numbers toggle a multi choice, typed text joins the picks as
// an answer no option offered, and enter sends the batch.
func TestAskIsAnsweredQuestionByQuestion(t *testing.T) {
m, k := testModel(t)
apply(m, askEvent())
m.Update(tea.KeyPressMsg{Code: '2', Text: "2"})
if v := m.View().Content; !strings.Contains(v, "Question 2 of 2") {
t.Fatalf("the card did not move to the second question:\n%s", v)
}
m.Update(tea.KeyPressMsg{Code: '1', Text: "1"})
m.Update(tea.KeyPressMsg{Code: '3', Text: "3"})
m.Update(tea.KeyPressMsg{Code: '1', Text: "1"})
typeText(m, "metrics")
run(m, press(m, "enter"))
if m.tr.OpenPrompt() != nil {
t.Fatal("the answered card stayed open")
}
want := `POST /answer {"answers":[{"QuestionID":"q1","Selected":["SQLite"]},{"QuestionID":"q2","Selected":["queue","metrics"]}],"id":"ask1"}`
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, want) {
t.Fatalf("answer call missing:\n%s", calls)
}
}
func TestAskEscDeclinesWithNothingSelected(t *testing.T) {
m, k := testModel(t)
apply(m, askEvent())
run(m, press(m, "esc"))
want := `POST /answer {"answers":[{"QuestionID":"q1","Selected":null},{"QuestionID":"q2","Selected":null}],"id":"ask1"}`
if calls := strings.Join(k.seen(), "\n"); !strings.Contains(calls, want) {
t.Fatalf("decline call missing:\n%s", calls)
}
}
// An @-token opens the menu as it is typed, and the chosen item replaces the
// token the kernel named — counted in UTF-16, so a CJK line splices where the
// kernel meant.
func TestCompletionReplacesTheTokenTheKernelNamed(t *testing.T) {
m, k := testModel(t)
for _, r := range "看 @no" {
_, cmd := m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
run(m, cmd)
}
if m.menu == nil || len(m.menu.c.Items) != 2 {
t.Fatalf("menu = %+v", m.menu)
}
if !strings.Contains(strings.Join(k.seen(), "\n"), "GET /complete") {
t.Fatal("the menu was not asked for")
}
run(m, press(m, "enter"))
if got := m.composer.Value(); got != "看 @notes.md " {
t.Fatalf("composer = %q", got)
}
if m.menu != nil {
t.Fatal("the menu stayed open after a file was chosen")
}
}
func TestUTF16Offsets(t *testing.T) {
line := "看 @no😀x"
for b, u := range map[int]int{0: 0, len("看"): 1, len("看 "): 2, len("看 @no"): 5, len("看 @no😀"): 7} {
if got := utf16At(line, b); got != u {
t.Errorf("utf16At(%d) = %d, want %d", b, got, u)
}
if got := byteAt(line, u); got != b {
t.Errorf("byteAt(%d) = %d, want %d", u, got, b)
}
}
}
// The task list is read from the kernel when it says the list moved, and drawn
// while it still has work in it.
func TestTodosFollowTheKernel(t *testing.T) {
m, _ := testModel(t)
_, cmd := m.Update(updateMsg{u: Update{Event: eventwire.Event{Kind: "todo_progress"}}, ok: true})
run(m, cmd)
v := m.View().Content
for _, want := range []string{"✓ read the code", "fixing the bug", "○ run tests"} {
if !strings.Contains(v, want) {
t.Fatalf("view missing %q:\n%s", want, v)
}
}
m.todos = []TodoItem{{Content: "done", Status: "completed"}}
if strings.Contains(m.View().Content, "Tasks") {
t.Fatal("a finished list stayed on screen")
}
}
+7 -3
View File
@@ -26,7 +26,7 @@ func renderItem(it *Item, width, shown int) string {
case ItemSay:
// Thinking with nothing said after it is a step, not an answer: it gets
// its marker and no speaker header.
if it.Text == "" {
if strings.TrimSpace(it.Text) == "" {
if it.Reasoning == "" {
return ""
}
@@ -50,7 +50,11 @@ func renderItem(it *Item, width, shown int) string {
}
return termrender.Dim(fmt.Sprintf(" ✗ declined %s %s", it.Approval.Tool, oneLine(it.Approval.Subject, width-20)))
case ItemAsk:
return termrender.Dim(" ? question → " + it.Verdict)
prompt := "question"
if len(it.Ask.Questions) > 0 {
prompt = it.Ask.Questions[0].Prompt
}
return termrender.Dim(" ? " + oneLine(prompt, width/2) + " → " + oneLine(it.Verdict, width/2))
case ItemNotice:
return renderNotice(it)
case ItemCompaction:
@@ -148,7 +152,7 @@ func renderReceipt(it *Item) string {
parts = append(parts, termrender.Yellow(fmt.Sprintf("%d gap(s)", n)))
}
mark := termrender.Green(" ✓ ")
if r.Verdict != "complete" {
if r.Verdict != "done" {
mark = termrender.Yellow(" ! ")
}
return mark + strings.Join(parts, termrender.Dim(" · "))
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
const todoRows = 8
type todosMsg struct {
items []TodoItem
err error
}
func (m *model) fetchTodos() tea.Cmd {
return func() tea.Msg {
items, err := m.client.Todos(m.ctx)
return todosMsg{items: items, err: err}
}
}
// todoLines draws the kernel's task list while it has work left. A list
// that ran to the end is spent, and keeping it up would read as the next
// turn already having a plan.
func (m *model) todoLines() []string {
open := false
for _, t := range m.todos {
if t.Status != "completed" {
open = true
}
}
if !open {
return nil
}
lines := []string{termrender.Dim(" Tasks")}
for i, t := range m.todos {
if i == todoRows {
lines = append(lines, termrender.Dim(" …"))
break
}
indent := " " + strings.Repeat(" ", max(t.Level, 0))
switch t.Status {
case "completed":
lines = append(lines, termrender.Dim(indent+"✓ "+oneLine(t.Content, m.width-10)))
case "in_progress":
label := t.Content
if t.ActiveForm != "" {
label = t.ActiveForm
}
lines = append(lines, termrender.Accent(indent+"▸ ")+termrender.Bold(oneLine(label, m.width-10)))
default:
lines = append(lines, indent+"○ "+oneLine(t.Content, m.width-10))
}
}
return lines
}
+3 -14
View File
@@ -14,7 +14,7 @@ import (
// View draws the live region: what is still changing, the composer and the
// status line. Everything settled is already in the terminal's scrollback.
func (m *model) View() tea.View {
live := m.liveLines()
live := append(append(m.liveLines(), m.todoLines()...), m.menuLines()...)
composer := m.composerLines()
status := m.statusLine()
// The live region is redrawn in place, so it must stay well inside the
@@ -35,7 +35,7 @@ func (m *model) View() tea.View {
lines[i] = ansi.Truncate(l, max(m.width-1, 1), "")
}
v := tea.NewView(strings.Join(lines, "\n"))
if c := m.composer.Cursor(); c != nil && m.tr.OpenPrompt() == nil {
if c := m.composer.Cursor(); c != nil && (m.tr.OpenPrompt() == nil || m.tr.OpenPrompt().Kind == ItemAsk) {
c.X += 2
c.Y += above
v.Cursor = c
@@ -63,7 +63,7 @@ func (m *model) liveLines() []string {
case it.Kind == ItemApproval && it.Verdict == "":
out = append(out, approvalCard(it, m.width)...)
case it.Kind == ItemAsk && it.Verdict == "":
out = append(out, askCard(it, m.width)...)
out = append(out, m.askCard(it)...)
default:
if r := renderItem(it, m.width, 0); r != "" {
out = append(out, strings.Split(r, "\n")...)
@@ -101,17 +101,6 @@ func approvalCard(it *Item, width int) []string {
return append(lines, termrender.Dim(" "+keys+" · n deny"))
}
func askCard(it *Item, width int) []string {
lines := []string{termrender.Accent(" ◇ ") + termrender.Bold("A question for you")}
for _, q := range it.Ask.Questions {
lines = append(lines, " "+oneLine(q.Prompt, width-6))
for i, o := range q.Options {
lines = append(lines, termrender.Dim(fmt.Sprintf(" %d. %s", i+1, oneLine(o.Label, width-10))))
}
}
return lines
}
func (m *model) composerLines() []string {
mark := termrender.Accent("› ")
if m.shell {