cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Run JEV questions in the background and explain the answers

Dami committed f10b29e
Queued questions go out as Bubble Tea commands, eight at a time, and
each answer recalculates the JEV cells. While answering, the status line
counts requests and the terminal's progress indicator (OSC 9;4) shows
activity. The context line shows the selected cell's answer and
confidence, or says a key is needed. Data > Ask JEV again re-asks the
selection. JEV is on only when TYPESAFE_API_KEY is found.
commit
f10b29e941222d693e75a7bf1533013d573241ef
parent
7adf89844e5374f83c4022f22f9493e295379de6
author
Dami <dami.etoile@gmail.com>
9 files changed, +307 -16
Mcmd/012/main.go26++++++-
Minternal/ui/format_test.go7+-
Ainternal/ui/jev.go136++++++++++++++++++++++++++++++
Ainternal/ui/jev_test.go113+++++++++++++++++++++++++
Minternal/ui/menubar.go4+-
Minternal/ui/model.go13++-
Minternal/ui/panel.go11++-
Minternal/ui/shell_test.go11++-
Minternal/ui/view.go2+
M cmd/012/main.go +25 -1
M internal/ui/format_test.go +2 -5
A internal/ui/jev.go +136 -0
@@ -0,0 +1,136 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "012/internal/jev"11+ "012/internal/sheet"12+)13+14+// JEV functions are answered in the background: the engine queues15+// questions in the cache, Update sends them as commands (a few at a time),16+// and each answer triggers a recalculation of the JEV cells.17+18+const (19+ jevParallel = 820+ jevTimeout = 60 * time.Second21+)22+23+type jevRunner struct {24+ client jev.Client25+ cache *jev.Cache26+}27+28+type jevAnswerMsg struct {29+ call sheet.RemoteCall30+ answer sheet.RemoteAnswer31+}32+33+// EnableJEV turns on JEV functions. The cache must already be installed34+// as sheet.Remote so loading a file queues its questions.35+func (m *Model) EnableJEV(client jev.Client, cache *jev.Cache) {36+ m.jev = &jevRunner{client: client, cache: cache}37+}38+39+func init() {40+ register(&command{id: "jev.refresh", title: "Ask JEV again",41+ desc: "Ask the JEV model again for the selected cells, replacing its answers",42+ enabled: func(m *Model) bool { return m.jev != nil },43+ run: func(m *Model) tea.Cmd {44+ r := m.selection()45+ var calls []sheet.RemoteCall46+ for _, a := range m.sheet.Addrs() {47+ if r.Contains(a) {48+ calls = append(calls, m.sheet.RemoteCalls(a)...)49+ }50+ }51+ if len(calls) == 0 {52+ m.note = "No JEV functions in " + r.String()53+ return nil54+ }55+ m.jev.cache.Forget(calls)56+ m.sheet.RecalcVolatile()57+ m.note = fmt.Sprintf("Asking JEV again for %d %s", len(calls), plural(len(calls), "question", "questions"))58+ return nil59+ }})60+}61+62+func plural(n int, one, many string) string {63+ if n == 1 {64+ return one65+ }66+ return many67+}68+69+// sendJEV starts queued questions, keeping at most jevParallel in flight70+// so a filled-down column doesn't open hundreds of connections at once.71+func (m *Model) sendJEV() tea.Cmd {72+ if m.jev == nil {73+ return nil74+ }75+ inFlight, _ := m.jev.cache.Busy()76+ calls := m.jev.cache.Take(jevParallel - inFlight)77+ cmds := make([]tea.Cmd, len(calls))78+ for i, call := range calls {79+ client := m.jev.client80+ cmds[i] = func() tea.Msg {81+ ctx, cancel := context.WithTimeout(context.Background(), jevTimeout)82+ defer cancel()83+ return jevAnswerMsg{call: call, answer: jev.Ask(ctx, client, call)}84+ }85+ }86+ return tea.Batch(cmds...)87+}88+89+// handleJEVAnswer stores an answer and recalculates the JEV cells.90+func (m *Model) handleJEVAnswer(msg jevAnswerMsg) {91+ if m.jev == nil {92+ return93+ }94+ m.jev.cache.Store(msg.call, msg.answer)95+ m.sheet.RecalcVolatile()96+}97+98+// jevBusy describes questions still being answered, for the status line.99+func (m *Model) jevBusy() string {100+ if m.jev == nil {101+ return ""102+ }103+ inFlight, queued := m.jev.cache.Busy()104+ switch {105+ case inFlight+queued == 0:106+ return ""107+ case queued == 0:108+ return fmt.Sprintf("JEV answering %d", inFlight)109+ }110+ return fmt.Sprintf("JEV answering %d, %d waiting", inFlight, queued)111+}112+113+// jevLine explains the JEV answer in the active cell, for the context line.114+func (m *Model) jevLine() string {115+ calls := m.sheet.RemoteCalls(m.cur)116+ if len(calls) == 0 {117+ return ""118+ }119+ if m.jev == nil {120+ return m.th.warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")121+ }122+ var parts []string123+ for _, c := range calls {124+ a, ok := m.jev.cache.Answer(c)125+ if !ok {126+ parts = append(parts, "JEV: asking…")127+ continue128+ }129+ parts = append(parts, jev.Describe(c, a))130+ }131+ line := parts[0]132+ for _, p := range parts[1:] {133+ line += " " + p134+ }135+ return m.th.muted.Render(line)136+}
A internal/ui/jev_test.go +113 -0
@@ -0,0 +1,113 @@
1+package ui2+3+import (4+ "context"5+ "strings"6+ "sync"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ typesafe "github.com/FelineStateMachine/typesafe-go"11+12+ "012/internal/jev"13+ "012/internal/sheet"14+)15+16+// fakeJEV answers every question the same way and counts requests.17+type fakeJEV struct {18+ mu sync.Mutex19+ calls int20+ noul float6421+}22+23+func (f *fakeJEV) SystemOne(_ context.Context, r typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {24+ f.mu.Lock()25+ defer f.mu.Unlock()26+ f.calls++27+ var a typesafe.Answer = typesafe.NoulAnswer{Noul: f.noul}28+ if _, ok := r.Questions["answer"].(typesafe.ChoiceQuestion); ok {29+ a = typesafe.ChoiceAnswer{Choice: "negative", Confidence: 0.8}30+ }31+ return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": a}}, nil32+}33+34+// jevModel returns a model with JEV on, answered by a fake.35+func jevModel(t *testing.T) (*Model, *fakeJEV) {36+ t.Helper()37+ fake := &fakeJEV{noul: 0.9}38+ cache := jev.NewCache()39+ prev := sheet.Remote40+ sheet.Remote = cache41+ t.Cleanup(func() { sheet.Remote = prev })42+ m := tallModel()43+ m.EnableJEV(fake, cache)44+ return m, fake45+}46+47+func TestJEVAnswersArriveInBackground(t *testing.T) {48+ m, fake := jevModel(t)49+ press(t, m, "The box was crushed", "<enter>")50+ // Typing the formula queues a question; its answer comes back as a51+ // message, which the press helper feeds in.52+ press(t, m, `=JEV.TEST(A1, "Is this a complaint?")`, "<enter>", "<up>")53+ if got := m.sheet.Value(addr("A2")); got.Kind != sheet.Bool || got.Num != 1 {54+ t.Fatalf("A2 = %+v", got)55+ }56+ if !strings.Contains(line(m, contextLine), "JEV: 90% likely yes") {57+ t.Errorf("context line %q", line(m, contextLine))58+ }59+ // Asking again with the same inputs is answered from the cache.60+ press(t, m, "<down>", `=JEV.PROB(A1, "Is this a complaint?")`, "<enter>")61+ if fake.calls != 1 {62+ t.Errorf("%d requests for one question", fake.calls)63+ }64+ if got := strings.TrimSpace(rowCells(m, 3, 1)[0]); got != "90%" {65+ t.Errorf("JEV.PROB shows %q", got)66+ }67+ // Ask JEV again forgets the answers for the selection.68+ press(t, m, "<up>", "<shift+down>", "<ctrl+k>", "ask jev", "<enter>")69+ if fake.calls != 2 {70+ t.Errorf("refresh made %d requests in total", fake.calls)71+ }72+}73+74+func TestJEVPendingAndBusy(t *testing.T) {75+ m, _ := jevModel(t)76+ // Send the formula without running the resulting commands, so the77+ // question stays in flight.78+ for _, r := range `=JEV.CLASSIFY("meh", "Sentiment", "positive, negative")` {79+ m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})80+ }81+ _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})82+ if !sheet.IsPending(m.sheet.Value(addr("A1"))) {83+ t.Fatalf("A1 = %+v", m.sheet.Value(addr("A1")))84+ }85+ status := line(m, m.height-1)86+ if !strings.Contains(status, "JEV answering 1") || m.View().ProgressBar == nil {87+ t.Errorf("status %q, progress %v", status, m.View().ProgressBar)88+ }89+ if got := strings.TrimSpace(rowCells(m, 1, 1)[0]); got != "Loading…" {90+ t.Errorf("pending cell shows %q", got)91+ }92+ run(m, cmd)93+ if got := m.sheet.Value(addr("A1")); got.Str != "negative" {94+ t.Errorf("A1 = %+v", got)95+ }96+ if strings.Contains(line(m, m.height-1), "JEV") || m.View().ProgressBar != nil {97+ t.Error("still busy after the answer")98+ }99+}100+101+func TestJEVWithoutKeyExplains(t *testing.T) {102+ prev := sheet.Remote103+ sheet.Remote = nil104+ t.Cleanup(func() { sheet.Remote = prev })105+ m := tallModel()106+ press(t, m, `=JEV.TEST("x", "Q")`, "<enter>", "<up>")107+ if !strings.Contains(line(m, contextLine), "TYPESAFE_API_KEY") {108+ t.Errorf("context line %q", line(m, contextLine))109+ }110+ if commands["jev.refresh"].enabled(m) {111+ t.Error("Ask JEV again is available without a key")112+ }113+}
M internal/ui/menubar.go +3 -1
M internal/ui/model.go +9 -4
@@ -91,8 +91,9 @@ type Model struct {
9191 pointPrefix string // entry text before the reference being pointed at9292 pointSuffix string // entry text after the caret while pointing9393 94- overlay overlay // open menu, palette or dialog, if any (modeMenu)95- lastFind *findBar // the last search, reopened by Ctrl+F94+ overlay overlay // open menu, palette or dialog, if any (modeMenu)95+ lastFind *findBar // the last search, reopened by Ctrl+F96+ jev *jevRunner // answers JEV functions; nil without an API key9697 prompt *prompt9798 files []string // file list shown by File Open9899 errMsg string
@@ -108,7 +109,8 @@ func New(s *sheet.Sheet, filename string) *Model {
108109 // Init implements tea.Model. It asks the terminal for its background color109110 // so the theme can adapt to light terminals.110111 func (m *Model) Init() tea.Cmd {111- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn))112+ // sendJEV starts any questions queued while loading the file.113+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.sendJEV())112114 }113115 114116 // Update implements tea.Model.
@@ -151,6 +153,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151153 m.handleLoaded(msg)152154 case filesMsg:153155 m.files = msg156+ case jevAnswerMsg:157+ m.handleJEVAnswer(msg)154158 }155159 // Scroll only when the focus moves, so the mouse wheel can look around156160 // without the view snapping back, as in Sheets.
@@ -158,7 +162,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
158162 if f := *m.focus(); f != before || m.mode != beforeMode {159163 m.scrollTo(f)160164 }161- return m, cmd165+ // Any edit may have queued JEV questions.166+ return m, tea.Batch(cmd, m.sendJEV())162167 }163168 164169 // beginUpdate prepares for an input event and returns the sheet's state
M internal/ui/panel.go +10 -1
M internal/ui/shell_test.go +7 -4
M internal/ui/view.go +2 -0