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>
| M | cmd/ | 26 | ++++++- |
| M | internal/ | 7 | +- |
| A | internal/ | 136 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 113 | +++++++++++++++++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 13 | ++- |
| M | internal/ | 11 | ++- |
| M | internal/ | 11 | ++- |
| M | internal/ | 2 | + |
@@ -6,9 +6,11 @@ import (
66 "fmt"77 "io/fs"88 "os"9+ "path/filepath"910 1011 tea "charm.land/bubbletea/v2"1112 13+ "012/internal/jev"1214 "012/internal/sheet"1315 "012/internal/ui"1416 )@@ -24,6 +26,24 @@ func run(args []string) error {
2426 if len(args) > 1 {2527 return errors.New("usage: 012 [file" + sheet.FileExt + "]")2628 }29+ // JEV functions run when an API key is set, in the environment or a30+ // .env file next to the sheet or in the current directory. The cache31+ // goes in before loading so the file's JEV cells queue their questions.32+ var jevClient jev.Client33+ var jevCache *jev.Cache34+ dirs := []string{"."}35+ if len(args) == 1 {36+ dirs = append(dirs, filepath.Dir(args[0]))37+ }38+ if cfg, ok := jev.LoadConfig(dirs...); ok {39+ c, err := jev.NewClient(cfg)40+ if err != nil {41+ return fmt.Errorf("JEV: %w", err)42+ }43+ jevClient, jevCache = c, jev.NewCache()44+ sheet.Remote = jevCache45+ }46+2747 s, name := sheet.New(), ""2848 if len(args) == 1 {2949 name = args[0]@@ -40,6 +60,10 @@ func run(args []string) error {
4060 }4161 }4262 }43- _, err := tea.NewProgram(ui.New(s, name)).Run()63+ m := ui.New(s, name)64+ if jevClient != nil {65+ m.EnableJEV(jevClient, jevCache)66+ }67+ _, err := tea.NewProgram(m).Run()4468 return err4569 }@@ -18,11 +18,8 @@ func rowCells(m *Model, r, n int) []string {
1818 x := rowHdrW1919 for i := range n {2020 w := m.sheet.ColWidth(m.left + i)21- seg := ""22- if x < len(l) {23- seg = l[x:min(x+w, len(l))]24- }25- out[i] = strings.TrimSpace(seg)21+ // Cut by display columns: cells may hold multi-byte text.22+ out[i] = strings.TrimSpace(ansi.Cut(l, x, x+w))2623 x += w2724 }2825 return out@@ -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+}@@ -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+}@@ -68,7 +68,9 @@ var menuBar = []menuDef{
6868 {cmd: "format.clear", title: "Clear formatting"},6969 }},7070 // Data: sort, filter and named ranges will go here.71- {title: "Data", accel: 'd'},71+ {title: "Data", accel: 'd', items: []menuItem{72+ {cmd: "jev.refresh"},73+ }},7274 {title: "Help", accel: 'h', items: []menuItem{7375 {cmd: "palette", title: "Search the menus"}, {cmd: "help"}, {cmd: "help.functions"}, sep,7476 {cmd: "help.about"},@@ -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@@ -36,6 +36,10 @@ func (m *Model) View() tea.View {
3636 v.AltScreen = true3737 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go3838 v.WindowTitle = "012 - " + m.displayName()39+ if m.jevBusy() != "" {40+ // Terminals that support it (OSC 9;4) show activity in the tab.41+ v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)42+ }3943 if m.changed {4044 v.WindowTitle += " (modified)"4145 }@@ -108,7 +112,9 @@ func (m *Model) contextLineText() string {
108112 case m.hint != "":109113 left = m.th.warning.Render(m.hint)110114 case m.mode == modeReady:111- left = m.readyLine()115+ if left = m.readyLine(); left == "" {116+ left = m.jevLine()117+ }112118 case m.mode == modeMenu:113119 switch o := m.overlay.(type) {114120 case *choiceBar:@@ -211,6 +217,9 @@ func (m *Model) statusLine() string {
211217 if m.sheet.Circular {212218 left += " " + m.th.warning.Render("Circular reference")213219 }220+ if busy := m.jevBusy(); busy != "" {221+ left += " " + m.th.hint.Render(busy)222+ }214223 var right string215224 if m.hasRange() && m.mode == modeReady {216225 r := m.selection()@@ -94,11 +94,14 @@ func TestVisibleItemsHidesMissingCommands(t *testing.T) {
9494 if s := strings.Join(got, ","); s != "Clear,-,Go to" {9595 t.Errorf("visible items %s", s)9696 }97- // Data has no registered commands yet, so the bar hides it.97+ // Menus only show once they have a registered command; Data's first98+ // is Ask JEV again.99+ found := false98100 for _, bm := range barMenus() {99- if bm.def.title == "Data" {100- t.Error("empty Data menu shown")101- }101+ found = found || bm.def.title == "Data"102+ }103+ if !found {104+ t.Error("Data menu hidden")102105 }103106 }104107 @@ -94,6 +94,8 @@ func (m *Model) gridRow(row int) string {
9494 base = m.th.selection9595 case m.found(a):9696 base = m.th.found97+ case sheet.IsPending(m.sheet.Value(a)):98+ base = m.th.muted9799 case m.sheet.Value(a).Kind == sheet.Error:98100 base = m.th.errorCell99101 default: