mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
ed499c4cac
commit
5ff9d7a0f5
8 files changed
+506
-20
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(" · "))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user