cat / 012
clone

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

Merge branch 'main' into worktree-agent-a0152d966ca1eb8be

Dami committed 072db59
commit
072db59473af5d363a9b0a24dd7cab1d6380077e
parent
3148d18100c3649ed66cb2e79575f84c65723be9
parent
20d8b4d6b627a301963de15727f4f71c7b91d4fa
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.4 of 3, Jev's estimate the message may not describe the diff
110 files changed, +5844 -236
MREADME.md4+
MROADMAP.md13+-
Mcmd/012/main.go26++-
Mcmd/012/mcp.go21++-
Mcmd/012/serve.go7+
Ademos/live.tape46++++
Ademos/liveagent/main.go140++++++++++++
Mdocs/agents/README.md1+
Adocs/agents/live.md230+++++++++++++++++++
Mdocs/agents/mcp.md4+-
Mdocs/contributing/architecture.md58++++-
Mdocs/contributing/testing.md10+-
Mdocs/contributing/ux.md1+
Adocs/media/live-ask-dark.pngbin
Adocs/media/live-ask-light.pngbin
Adocs/media/live-review-dark.pngbin
Adocs/media/live-review-light.pngbin
Adocs/media/live-suggestion-dark.pngbin
Adocs/media/live-suggestion-light.pngbin
Adocs/media/live.gifbin
Mdocs/media/palette-dark.pngbin
Mdocs/media/palette-light.pngbin
Mdocs/reference/command-line.md2+
Mdocs/reference/keys.md12+
Mdocs/terminal/ssh.md7+
Me2e/harness_test.go2+
Ae2e/live_test.go243++++++++++++++++++++
Me2e/screens_test.go6+
Me2e/stills_test.go3+
Me2e/stillshapes_test.go2+
Ae2e/testdata/screens/docs-live-ask-light.html10+
Ae2e/testdata/screens/docs-live-ask.html10+
Ae2e/testdata/screens/docs-live-review-light.html12+
Ae2e/testdata/screens/docs-live-review.html12+
Ae2e/testdata/screens/docs-live-suggestion-light.html12+
Ae2e/testdata/screens/docs-live-suggestion.html12+
Me2e/testdata/screens/docs-palette-light.html2+-
Me2e/testdata/screens/docs-palette.html2+-
Me2e/testdata/screens/help-light.html2+-
Me2e/testdata/screens/help-narrow.html2+-
Me2e/testdata/screens/help-wide.html58++--
Me2e/testdata/screens/help.html2+-
Ae2e/testdata/screens/live-ask-light.html30++
Ae2e/testdata/screens/live-ask.html30++
Ae2e/testdata/screens/live-review-light.html30++
Ae2e/testdata/screens/live-review.html30++
Ae2e/testdata/screens/live-suggestion-high-contrast.html30++
Ae2e/testdata/screens/live-suggestion-light.html30++
Ae2e/testdata/screens/live-suggestion.html30++
Me2e/testdata/screens/menu-download.html30+-
Me2e/testdata/screens/menu-light.html30+-
Me2e/testdata/screens/menu-settings.html42++--
Me2e/testdata/screens/menu.html30+-
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html2+-
Me2e/testdata/screens/theme-classic-narrow-menu.html28+-
Me2e/testdata/screens/theme-high-contrast-menu.html30+-
Me2e/testdata/screens/theme-light-menu.html30+-
Ainternal/cowork/agent.go363++++++++++++++++++++++++++++++
Ainternal/cowork/ask.go284+++++++++++++++++++++++
Ainternal/cowork/attach.go125++++++++++
Ainternal/cowork/board.go250+++++++++++++++++++++
Ainternal/cowork/board_test.go337++++++++++++++++++++++++++++
Ainternal/cowork/cowork.go109+++++++++
Ainternal/cowork/endpoint.go158+++++++++++++
Ainternal/cowork/listen.go276+++++++++++++++++++++++
Ainternal/cowork/listen_test.go220++++++++++++++++++
Ainternal/cowork/peer_bsd.go15+
Ainternal/cowork/peer_linux.go13+
Ainternal/cowork/peer_other.go12+
Ainternal/cowork/peer_unix.go56+++++
Minternal/mcp/books.go13+
Ainternal/mcp/live.go170++++++++++++++
Minternal/mcp/read.go2+-
Minternal/mcp/roots.go10+-
Minternal/mcp/server.go11+-
Minternal/mcp/write.go45+++-
Minternal/room/registry.go22++-
Minternal/room/room.go91++++++-
Minternal/room/room_test.go50++++
Ainternal/serve/agents.go44++++
Minternal/sheet/authors.go16+
Ainternal/sheet/proposal.go279+++++++++++++++++++++++
Ainternal/sheet/proposal_test.go165++++++++++++++
Minternal/sheet/randedit_shared_test.go60++++-
Ainternal/ui/agentask.go153+++++++++++++
Ainternal/ui/agentdraw.go144++++++++++++
Ainternal/ui/agents.go305+++++++++++++++++++++++++
Ainternal/ui/agents_test.go288++++++++++++++++++++++++
Minternal/ui/file.go2+-
Minternal/ui/hosts.go2+
Minternal/ui/menubar.go1+
Minternal/ui/model.go5+-
Minternal/ui/panel.go2+-
Minternal/ui/pipe_test.go2+-
Minternal/ui/prefs.go2+
Ainternal/ui/review/review.go354+++++++++++++++++++++++++++++
Ainternal/ui/review/review_test.go122++++++++++
Minternal/ui/rooms.go1+
Minternal/ui/share.go24++-
Minternal/ui/sharecmds.go12+-
Minternal/ui/sharedraw.go12+-
Minternal/ui/sharemono_test.go23++
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go7+
Minternal/ui/view.go3+
Mscripts/doclint/main.go4+-
M README.md +4 -0
M ROADMAP.md +1 -12
@@ -33,18 +33,6 @@ completed by nu as they're written.
3333 |---|---|---|3434 | A command's signature and description for the word at the caret, on the context line, through `nu --ide-hover` or `nu --lsp` | Writing a cell without leaving for `help` | S |3535 36-### 3. Agents37-38-Agents work on workbooks through the same operations people do (the39-`Batch`/`Change` path), so their edits are undoable, attributed and40-shown by `012 diff`, under the same trust rules as macros. Live mode is41-`012 serve`'s shared editing with an agent as one participant42-([Architecture](docs/contributing/architecture.md#shared-workbooks)).43-44-| Step | Result | Size |45-|---|---|---|46-| Live mode: `012 --listen` and `012 mcp --attach`; the agent's cursor and name in the grid, its changes arriving as a suggestion (marked cells, accepted or rejected whole or by cell) unless direct edits are allowed, its own undo, a scope (sheet, range, read-only), elicitation to ask the person | Coworking with an agent in the grid | M to L |47-4836 ## Shelved4937 5038 Explored, not scheduled; the fit is still open.
@@ -149,6 +137,7 @@ needs cgo.
149137 - `012 describe`, results as JSON with stable schemas, `012 set --dry-run` as a diff, and a Claude Code skill installed by `012 agent --install-skill`: [Agents](docs/agents/README.md)150138 - `012 mcp`, an MCP server on the workbooks in its folders, each tool taking a path, added to hosts by `012 agent --install-mcp`: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)151139 - Views: ranges read and charts made drawn in the chat from the HTML export, in MCP Apps hosts and the OpenAI Apps SDK's: [MCP server](docs/agents/mcp.md#views-in-the-chat)140+- Live mode: `012 --listen` and `012 mcp --attach`, the agent a participant in the session's room, its cursor and name in the grid, its changes suggestions accepted or rejected whole or by cell unless direct edits are allowed, its own undo, a scope, and questions on the context line: [Live mode](docs/agents/live.md)152141 153142 **Upkeep**154143
M cmd/012/main.go +25 -1
@@ -76,6 +76,7 @@ func usage() error {
7676 return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +7777 " sheet, or a .csv, .tsv, .json, .nuon, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +7878 " 012 [flags] -: a table from standard input (NUON, JSON, CSV or TSV)\n" +79+ " 012 [flags] --listen [file]: let agents work in the session, attached with 012 mcp --attach (see docs/agents/live.md)\n" +7980 " 012 [flags] --pipe [--to nuon|json|csv|tsv] [--send ask|selection|sheet] [file]: on quitting, send the table to standard output\n" +8081 " 012 nu [flags] [file]: the workbook's nushell notebook (see docs/nushell/notebooks.md)\n" +8182 " 012 nu --module | --install-module [--force] [path]: the nushell module with sheet (see docs/nushell/README.md)\n" +
@@ -83,6 +84,7 @@ func usage() error {
8384 " 012 get|set|recalc|export|describe file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +8485 " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +8586 " 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]: an MCP server on the workbook (see docs/agents/mcp.md)\n" +87+ " 012 mcp --attach [name]: an MCP server in a running session listening for agents (see docs/agents/live.md)\n" +8688 " 012 agent --skill | --install-skill [--force] [dir]: the Claude Code skill (see docs/agents/README.md)\n" +8789 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +8890 " 012 version")
@@ -115,6 +117,7 @@ func run(args []string, e env) error {
115117 if err != nil {116118 return usage()117119 }120+ listen, args := cutFlag(args, "--listen")118121 pipe, args, err := parsePipe(args)119122 if err != nil {120123 return err
@@ -148,6 +151,7 @@ func run(args []string, e env) error {
148151 Notes: configNotes(cfg),149152 // Where a crash keeps unsaved work: crash.go.150153 RecoveryDir: recoveryDir(),154+ Version: buildVersion(),151155 }152156 client, notes := startJEV(cfg, e, settings.Keys)153157 settings.Notes = append(settings.Notes, notes...)
@@ -161,13 +165,19 @@ func run(args []string, e env) error {
161165 if offersKept(args, pipe.stdin) {162166 m.OfferKept()163167 }168+ if listen {169+ m.Listen() // live mode: agents attach with 012 mcp --attach170+ }164171 setPipe(m, pipe, e.stdin)165172 opts, closeTTY, err := tuiOptions(pipe, e)166173 if err != nil {167174 return err168175 }169176 g := ui.Guard(m)170- err = e.runTUI(g, opts...)177+ // In rooms, so the session can share its workbook with agents178+ // (live mode, docs/agents/live.md).179+ err = e.runTUI(ui.InRooms(g), opts...)180+ m.StopListening()171181 closeTTY()172182 if c := g.Finish(err); c != nil {173183 return crashed(g, c)
@@ -178,6 +188,20 @@ func run(args []string, e env) error {
178188 return sendPiped(m, pipe, e.stdout)179189 }180190 191+// cutFlag takes flag out of args, reporting whether it was there.192+func cutFlag(args []string, flag string) (bool, []string) {193+ var rest []string194+ found := false195+ for _, a := range args {196+ if a == flag {197+ found = true198+ continue199+ }200+ rest = append(rest, a)201+ }202+ return found, rest203+}204+181205 // loadConfig reads the config file with the environment and flags.182206 func loadConfig(e env, flags map[string]string) (*config.Config, error) {183207 path, err := config.DefaultPath()
M cmd/012/mcp.go +19 -2
@@ -12,6 +12,7 @@ import (
1212 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1313 1414 "github.com/FelineStateMachine/012/internal/config"15+ "github.com/FelineStateMachine/012/internal/cowork"1516 "github.com/FelineStateMachine/012/internal/headless"1617 "github.com/FelineStateMachine/012/internal/jev"1718 "github.com/FelineStateMachine/012/internal/mcp"
@@ -24,19 +25,21 @@ import (
2425 // working directory. A file named is the default of tools called2526 // without a path.2627 27-const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]"28+const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]\n 012 mcp --attach [name|socket]: join a running 012 session listening for agents (see docs/agents/live.md)"2829 2930 // runMCP is 012 mcp: it serves until the client closes standard input.3031 // Nothing runs a program or reaches the network unless its flag says:3132 // --notebooks offers run_notebook_cell, --jev answers JEV functions.3233 func runMCP(args []string, e env) error {33- a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})34+ a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help", "attach"})3435 switch {3536 case err != nil:3637 return usageError(err.Error(), mcpUsage)3738 case a.has("help"):3839 fmt.Fprintln(e.stdout, mcpUsage)3940 return nil41+ case a.has("attach"):42+ return attach(a, e)4043 case len(a.pos) > 1:4144 return usageError("", mcpUsage)4245 case a.has("trust") && !a.has("notebooks"):
@@ -75,6 +78,20 @@ func runMCP(args []string, e env) error {
7578 return mcp.New(o).Run(ctx, t)7679 }7780 81+// attach is 012 mcp --attach [name|socket]: live mode, the agent a82+// participant of a running session, whose person decides what it may83+// do, so no other flag goes with it.84+func attach(a cliArgs, e env) error {85+ if len(a.flags) > 1 || len(a.pos) > 1 {86+ return usageError("--attach takes a session's name or socket and no other flag: the session decides what the agent may do", mcpUsage)87+ }88+ target := ""89+ if len(a.pos) == 1 {90+ target = a.pos[0]91+ }92+ return cowork.Attach(target, e.stdin, e.stdout)93+}94+7895 // rootDirs are the folders --root gave, else the working directory,7996 // else, when that's the file system's root (where some hosts start8097 // servers), the home folder.
M cmd/012/serve.go +7 -0
A demos/live.tape +46 -0
A demos/liveagent/main.go +140 -0
@@ -0,0 +1,140 @@
1+// Command liveagent is the agent of the live-mode tape (live.tape): it2+// writes a household budget, then attaches to the 012 listening on it3+// with 012 mcp --attach, as a host would, and works through a script:4+// it points at the bills, suggests March's figures, waits for the5+// person to settle them, asks whether to add a line for the gym and,6+// told yes, suggests it.7+package main8+9+import (10+ "bufio"11+ "encoding/json"12+ "flag"13+ "fmt"14+ "os"15+ "os/exec"16+ "time"17+)18+19+const budget = `{"version": 2, "cells": {"A1": "Household budget", "A3": "Rent", "B3": "1200", "A4": "Food", "B4": "450",20+ "A5": "Power", "B5": "90", "A6": "Total", "B6": "=SUM(B3:B5)"}}`21+22+func main() {23+ write := flag.String("write", "", "write the budget to this file and exit")24+ bin := flag.String("012", "../bin/012", "the 012 to attach with")25+ flag.Parse()26+ if *write != "" {27+ if err := os.WriteFile(*write, []byte(budget), 0o644); err != nil {28+ fail(err)29+ }30+ return31+ }32+ a, err := attach(*bin)33+ if err != nil {34+ fail(err)35+ }36+ a.script()37+}38+39+func fail(err error) {40+ fmt.Fprintln(os.Stderr, "liveagent:", err)41+ os.Exit(1)42+}43+44+// agent speaks MCP's JSON-RPC to 012 mcp --attach.45+type agent struct {46+ in *json.Encoder47+ out *bufio.Reader48+ next int49+}50+51+// attach starts 012 mcp --attach once a session listens, and52+// initializes the connection as the host called claude.53+func attach(bin string) (*agent, error) {54+ time.Sleep(3 * time.Second) // the session starts listening55+ cmd := exec.Command(bin, "mcp", "--attach")56+ in, err := cmd.StdinPipe()57+ if err != nil {58+ return nil, err59+ }60+ out, err := cmd.StdoutPipe()61+ if err != nil {62+ return nil, err63+ }64+ if err := cmd.Start(); err != nil {65+ return nil, err66+ }67+ a := &agent{in: json.NewEncoder(in), out: bufio.NewReader(out)}68+ a.call("initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{},69+ "clientInfo": map[string]any{"name": "claude", "version": "1"}})70+ a.in.Encode(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})71+ return a, nil72+}73+74+// call sends a request and returns its result.75+func (a *agent) call(method string, params any) map[string]any {76+ a.next++77+ a.in.Encode(map[string]any{"jsonrpc": "2.0", "id": a.next, "method": method, "params": params})78+ for {79+ line, err := a.out.ReadBytes('\n')80+ if err != nil {81+ fail(err)82+ }83+ var msg struct {84+ ID int `json:"id"`85+ Result map[string]any `json:"result"`86+ Error json.RawMessage `json:"error"`87+ }88+ if json.Unmarshal(line, &msg) != nil || msg.ID != a.next {89+ continue90+ }91+ if msg.Error != nil {92+ fail(fmt.Errorf("%s: %s", method, msg.Error))93+ }94+ return msg.Result95+ }96+}97+98+// tool calls a tool and returns its structured result.99+func (a *agent) tool(name string, args map[string]any) map[string]any {100+ res := a.call("tools/call", map[string]any{"name": name, "arguments": args})101+ out, _ := res["structuredContent"].(map[string]any)102+ return out103+}104+105+// settled waits until none of the agent's suggestions is pending.106+func (a *agent) settled() {107+ for {108+ list, _ := a.tool("suggestions", nil)["suggestions"].([]any)109+ pending := false110+ for _, s := range list {111+ if s.(map[string]any)["state"] == "pending" {112+ pending = true113+ }114+ }115+ if !pending {116+ return117+ }118+ time.Sleep(300 * time.Millisecond)119+ }120+}121+122+func (a *agent) script() {123+ a.tool("read_range", map[string]any{"ref": "A1:B6"})124+ a.tool("focus", map[string]any{"ref": "B3:B5"})125+ time.Sleep(1500 * time.Millisecond)126+ a.tool("write_cells", map[string]any{"message": "March's bills came in higher",127+ "entries": []map[string]any{{"ref": "B4", "input": "480"}, {"ref": "B5", "input": "95"}}})128+ a.settled()129+ time.Sleep(1200 * time.Millisecond)130+ answer := a.tool("ask", map[string]any{"message": "Add a line for the gym?"})131+ if answer["action"] != "accept" {132+ return133+ }134+ a.tool("apply_operations", map[string]any{"message": "the gym, as you said", "operations": []map[string]any{135+ {"op": "insert_rows", "ref": "A6"},136+ {"op": "set", "ref": "A6", "input": "Gym"},137+ {"op": "set", "ref": "B6", "input": "40"}}})138+ a.settled()139+ time.Sleep(3 * time.Second)140+}
M docs/agents/README.md +1 -0
A docs/agents/live.md +230 -0
@@ -0,0 +1,230 @@
1+---2+title: "Live mode"3+sidebar_position: 34+---5+6+# Live mode7+8+In live mode the agent works in your running 012 with you, as one more9+person in the workbook: its pointer and name show in the grid, it reads10+the workbook as it is on your screen, and what it changes arrives as a11+suggestion you accept or reject, whole or cell by cell. Here claude12+suggests March's food and power bills; the cells it would change carry13+a ◇, and the context line says what it would put in the one under the14+pointer, and why:15+16+![A budget with claude in it: its ◆ on the status line and on row 4's header, a ◇ on B4 and B5, the context line saying claude suggests 480 for B4, which holds 450, because March's bills came in higher, and the status line counting 1 suggestion](../media/live-suggestion-dark.png#gh-dark-mode-only)17+![A budget with claude in it: its ◆ on the status line and on row 4's header, a ◇ on B4 and B5, the context line saying claude suggests 480 for B4, which holds 450, because March's bills came in higher, and the status line counting 1 suggestion](../media/live-suggestion-light.png#gh-light-mode-only)18+19+It is [`012 serve`'s shared editing](../terminal/ssh.md#sharing-a-workbook)20+with the agent as a participant: the session's workbook sits in a room,21+every change goes through the workbook's one mutation path in the order22+the room takes them, each attributed to whoever made it, so undo takes23+back only your own and `012 diff` shows everyone's. The agent uses the24+[MCP server's](mcp.md) tools, on this one workbook.25+26+Claude, attached to a household budget, points at the bills and27+suggests March's figures; you review and accept them, answer its28+question, and accept the line it then suggests:29+30+![claude in the budget: its pointer on the bills, its suggestion marked on B4 and B5 and accepted from the review panel, its question on the context line answered yes, and the gym line it then suggests accepted](../media/live.gif)31+32+## Starting33+34+Let agents in when you start, or from a session already running:35+36+```sh37+012 --listen budget.01238+```39+40+**File > Invite an agent** asks what the agent may change (its41+[scope](#scope)), and starts listening if the session wasn't;42+`--listen` lets it suggest changes to the whole workbook. **File > Stop43+inviting agents** closes the socket: agents attached leave, and their44+suggestions stay for you to settle.45+46+The agent's host runs `012 mcp --attach` as its MCP server, which joins47+the session:48+49+```sh50+claude mcp add --scope user 012-live -- 012 mcp --attach # Claude Code51+012 mcp --attach # the only session listening52+012 mcp --attach budget # the session with budget.012 open53+```54+55+With no name, `--attach` joins the only session listening, or lists56+them when there are several; a name picks the session by its workbook57+(`budget`, `budget.012`) or its process id, and a path ending in58+`.sock` names a socket. The agent's name in the grid is its host's59+(`Claude Code`), as its MCP `initialize` request gives it.60+61+```mermaid62+sequenceDiagram63+ participant H as MCP host (the agent)64+ participant A as 012 mcp --attach65+ participant S as 012 --listen (your session)66+ participant R as the room67+ H->>A: initialize (its name)68+ A->>S: the socket: hello, the agent's name69+ S->>R: a seat for the agent70+ S-->>A: welcome: the workbook, the scope71+ H->>S: tools/call write_cells (through A)72+ S->>R: a turn: the change made on a copy, checked against the scope73+ R-->>S: a suggestion on the board; your screen marks its cells74+ S-->>H: the suggestion's number75+ Note over S: you accept (Ctrl+Alt+A, Enter)76+ S->>R: your turn: the cells set, one step in the agent's name77+ R-->>H: the suggestions resource updated78+```79+80+## In the grid81+82+The agent shows as the others in a shared workbook do, with a ◆ where83+a person has their initial: on the header of the row its pointer is on,84+before its name on the status line, and in **File > Who's here**, which85+lists it with the word agent. Its pointer's cell is in its color, double86+underlined. The agent moves it with the `focus` tool as it works, and87+each change it makes or suggests puts it on the cells changed, so you88+see where it's looking.89+90+## Suggestions91+92+What the agent changes waits as a suggestion unless you93+[let agents edit directly](#direct-edits). A cell it would change has a94+◇ in its top-left corner; with the pointer on one, the context line95+says whose suggestion it is, what it would put there, what the cell96+holds and the agent's message. The status line counts the suggestions97+waiting.98+99+**Review suggestions** (Ctrl+Alt+A, or File > Review suggestions) lists100+them at the right of the grid, by agent and message, each with its101+cells; the grid shows the cell of the row highlighted:102+103+![The suggestions panel over the budget: claude's suggestion, March's bills came in higher, highlighted with its accept and reject chips, and its two cells, B4 from 450 to 480 and B5 from 90 to 95](../media/live-review-dark.png#gh-dark-mode-only)104+![The suggestions panel over the budget: claude's suggestion, March's bills came in higher, highlighted with its accept and reject chips, and its two cells, B4 from 450 to 480 and B5 from 90 to 95](../media/live-review-light.png#gh-light-mode-only)105+106+| Key | On a suggestion | On one of its cells |107+|---|---|---|108+| Enter, A, or click ✓ | Accept all it has waiting | Accept the cell |109+| R, Del, or click ✗ | Reject all it has waiting | Reject the cell |110+| Shift+A, Shift+R | Accept or reject every suggestion | |111+112+What you accept is made as one step, in the agent's name: its cells113+show as changed by it, the agent's `undo` takes it back, and your own114+undo passes it by, as it passes by another person's step. A change that115+does more than set cells (inserting rows, adding a sheet, a chart, a116+filter) is listed with what else it does and is accepted whole, by117+making it again on the workbook; a change of cells alone sets the cells118+it showed you. Rejecting changes nothing. Either way the agent hears119+of it: the `suggestions` resource is updated for hosts subscribed to120+it, and its next write lists what became of its earlier ones.121+122+### Direct edits123+124+**File > Let agents edit directly** skips suggestions: agents' changes125+are made at once, each its own step, still theirs to undo. It is off126+when a session starts, holds for the session, and applies to every127+agent in it.128+129+## Scope130+131+When you invite the agent you pick what it may change:132+133+| Scope | The agent may change |134+|---|---|135+| The whole workbook | Anything, as you could |136+| This sheet | The shown sheet's cells, lines, charts, rules and filters; no other sheet, and not the sheet list, names or settings |137+| The selection | The cells of the selected range only |138+| Read only | Nothing: it reads, points and asks |139+140+A change outside the scope is refused before it reaches you, with the141+reason, and the agent's pointer stays inside it. The scope bounds142+changes, not reading: formulas in the scope read cells outside it, so143+the agent reads the whole workbook. Invite it again to change the scope.144+145+## Notebook cells and JEV146+147+What reaches beyond the workbook needs your leave each time, or for the148+session, as an [untrusted macro](../sheets/macros.md) does. The agent's149+`run_notebook_cell` asks before your session runs the cell with nu as150+you, with your files; with the whole workbook in scope only, as its151+output goes to the sheets it's sent to. With direct edits on, a change152+that enters [JEV formulas](../formulas/jev.md), which send cells' values153+over the network with your API key, asks first; a suggestion doesn't,154+as accepting it is your leave. The question takes the context line:155+Enter allows once, A allows for the session, Esc denies.156+157+## Questions158+159+The agent's `ask` tool puts a question on your context line and waits160+for your answer:161+162+![claude asking on the context line whether to overwrite B3 to B5 with March's figures, with Enter for yes, N for no and Esc to cancel, and the whole question on the status line](../media/live-ask-dark.png#gh-dark-mode-only)163+![claude asking on the context line whether to overwrite B3 to B5 with March's figures, with Enter for yes, N for no and Esc to cancel, and the whole question on the status line](../media/live-ask-light.png#gh-light-mode-only)164+165+It takes what an MCP elicitation request takes, a message and a flat166+object schema, and returns what an elicitation returns: `accept` with167+the values, `decline` or `cancel`. MCP's elicitation goes from a server168+to the user of its host; here you are on the server's side, so the169+agent asks through a tool shaped like one. A question without a schema170+is a yes or no (Enter, N, Esc); otherwise each property is asked in171+turn: a boolean with Y and N, a choice (`enum`, or `oneOf` consts, up172+to nine) with its number, a string, number or integer typed and173+entered. Esc cancels. The question waits until you're in READY with174+nothing open, and goes away if the agent stops waiting.175+176+## Tools177+178+The agent has the [MCP server's](mcp.md) tools, working on the one179+workbook: `path` is left out. Writes return the suggestion's number,180+or with direct edits the change as saved, and the news of earlier181+suggestions. `create_workbook` isn't offered. Live mode adds:182+183+| Tool | Does |184+|---|---|185+| `focus` | Moves the agent's pointer to a cell or range, in its scope |186+| `undo` | Takes back the agent's latest step that was made (accepted, or made directly); refused when someone changed the same cells since |187+| `ask` | Asks you a question and waits for the answer |188+| `suggestions` | The agent's suggestions and what became of each, and its scope |189+| `run_notebook_cell` | Has your session run a notebook cell, once you allow it |190+191+The resource `o12://live/suggestions` lists the same as `suggestions`;192+hosts subscribed to it are told when you settle one.193+194+## 012 serve195+196+[`012 serve`](../terminal/ssh.md) listens the same way when sharing is197+on, and the agent on the server, run by the server's user, joins a file198+someone has open by naming it: `012 mcp --attach budget.012`. It may199+change the whole workbook, as a suggestion anyone in the room accepts200+or rejects; its questions go to whoever is free first.201+202+## Security203+204+- A session listens on a Unix socket only you can reach: the socket is205+ `0600`, in a folder that is `0700`, `$XDG_RUNTIME_DIR/012` or else206+ `agents` in [012's config folder](../reference/config.md). Nothing207+ listens on the network.208+- The session asks the kernel who connected (`SO_PEERCRED` on Linux,209+ `LOCAL_PEERCRED` on macOS and FreeBSD) and refuses any other user.210+ `012 mcp --attach` refuses a socket or folder another user owns or211+ can reach.212+- On Windows the socket is in your profile's folder, whose permissions213+ keep other users out; there is no kernel check of who connected.214+- The agent can do what the scope and your grants allow, as you, in215+ this workbook only: it opens no files, runs nothing and reaches no216+ network without your leave.217+- A process of yours can attach, as it could already read and write218+ your files; stop inviting agents to close the socket.219+220+## Limits221+222+- A change is made on a copy of the workbook first, to show it and223+ check its scope, so a suggestion costs a save and an open of the224+ workbook: quick for everyday workbooks, seconds near the225+ [`max-cells` budget](../contributing/limits.md#sheet-size).226+- The agent's changes are made in the order the room takes them, as227+ anyone's; accepting a suggestion sets the cells it showed, over what228+ anyone typed in them since.229+- The agent attaches to the workbook it was invited to: opening another230+ file in the session stops inviting agents.
M docs/agents/mcp.md +3 -1
M docs/contributing/architecture.md +46 -12
@@ -539,7 +539,7 @@ draw. The components:
539539 | edit line | `lineedit.Line` | the one-line editor shared by cell entries, prompts and search fields (package `lineedit`) |540540 | cell entry | `entry`, `suggest.List` | typing into a cell, pointing at references, other sheets while pointing (`entry.go`, `assistsheets.go`); formula suggestions and signatures (package `suggest`, with `assist.go`) |541541 | prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |542-| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker (package `filterpick`, with `filter.go`), the sort bar (package `sortbar`, with `sort.go`), choice bars (package `choicebar`, with `dialog.go`), the chart editor and selection (`charteditor.go`, `chartsel.go`), the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts (package `shortcuts`, with `help.go`), Evaluate formula (package `evalview`, with `evaluate.go`) |542+| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker (package `filterpick`, with `filter.go`), the sort bar (package `sortbar`, with `sort.go`), choice bars (package `choicebar`, with `dialog.go`), the chart editor and selection (`charteditor.go`, `chartsel.go`), the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts (package `shortcuts`, with `help.go`), Evaluate formula (package `evalview`, with `evaluate.go`), the agents' suggestions (package `review`, with `agents.go`) |543543 | sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |544544 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |545545 | import | `transfer.Transfer`, `pipeState` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`); standard input read as a sheet, and what a pipeline gets on quitting (`pipe.go`) |
@@ -576,6 +576,7 @@ methods off `ui.Model`'s exported API:
576576 | `nbview` | `nbview.Host` | theme, locale, the cells, a cell's output, how its run stands, run a command, keep a cell's new source (7); the language's highlighter, completer and checker are `nbview.Providers`, small interfaces of their own |577577 | `srcview` | `srcview.Host` | theme, locale, the source's columns, how many rows the tab shows, a row, ask for rows (6); the rows come from `paged.Pages`, read a page at a time |578578 | `suggest` | `suggest.Host` | theme, size, the edit line, whether an entry is being typed, the entry's sheet, the formula as parsed, where the formula bar's text starts (7) |579+| `review` | `review.Host` | theme, size, close, the agents' suggestions waiting, accept, reject, show a cell (7) |579580 | `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep, whether keys can be held (10) |580581 | `rules` | `rules.Host` | theme, size, the edit line, close, the sheet and selection, save a conditional format or a validation rule, follow a rule removed or moved (each recorded as the commands that do it), the terminal's palette colors (10) |581582 | `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the trace, the sheet and cell shown, show a cell, note, run a replacement (recorded as Find and replace), leave keeping the search, pass a click to the grid (11) |
@@ -621,7 +622,7 @@ hints) are what every overlay is drawn with.
621622 **Packages.** `theme`, `rowtext`, `formula`, `overlay` and `lineedit`622623 depend on nothing in `ui`, so they can be tested and measured alone. The623624 components in `picker`, `cmdline`, `nbview`, `srcview`, `themepicker`, `findbar`, `rules`,624-`sortbar`, `filterpick`, `choicebar`, `shortcuts`, `evalview`, `suggest`, `tabstrip`625+`sortbar`, `filterpick`, `choicebar`, `shortcuts`, `evalview`, `suggest`, `review`, `tabstrip`625626 and `transfer` build on them and reach the model only through their626627 hosts, with unit tests of their own against fake hosts. A component moves out of package627628 `ui` when its host stays small (about ten methods or fewer); one that
@@ -920,8 +921,9 @@ changed; everything else it does, it does through its seat:
920921 921922 | `room.Participant` | What it is |922923 |---|---|923-| `Name()` | who they are to the others: the SSH user |924+| `Name()` | who they are to the others: the SSH user, or the agent's MCP host |924925 | `Notify()` | the room changed; called with the room locked, so it only schedules a turn (a session sends its program a `roomMsg`) |926+| `Agent()`, optional (`room.Agent`) | the participant is one of the agents: marked as one, never the keeper, and the room closes when its last person leaves |925927 926928 | `room.Seat` | What it does |927929 |---|---|
@@ -931,15 +933,6 @@ changed; everything else it does, it does through its seat:
931933 | `Saved`, `SetSaved`, `Value`, `Keeper`, `Post`, `Take` | what the room shares: its file's saved state, values such as the notebook runs, and messages for whoever keeps it |932934 | `Leave` | the seat given up; reports whether the room closed |933935 934-The interface stays this small so an MCP client attached to the935-server can be a participant beside sessions (ROADMAP.md, Agents):936-`internal/mcp`'s tools stand on a `Backend` of `View`, `Scratch` and937-`Change`, and a room's would be a seat, `View` and `Change` its turns938-(the change one `Batch`, attributed to the client's seat, so its own939-undo and the others' marks follow), `Scratch` a copy of the workbook940-taken in a turn, and `Notify` what tells the client the workbook941-changed.942-943936 **Unsaved work.** `Server.Shutdown` closes a channel every session944937 watches; a session quitting on it, or on its idle timeout, asks its945938 model `Unsaved` and `Recover`, which writes the workbook to
@@ -955,6 +948,47 @@ root (the local app) uses them as they are, a served session's root
955948 keeps them inside its directory. Every open, save, import, download and956949 file listing goes through `Model.path` or the root.957950 951+### Live mode952+953+Agents take part in rooms as participants ([Live mode](../agents/live.md)),954+through `internal/cowork`. A local session invited to (`012 --listen`,955+File > Invite an agent) takes its workbook into a room of its own as it956+is (`enterLocal`: its steps adopted as its person's, `Workbook.Adopt`),957+runs under `ui.Shared` as served sessions do, waits for its room's958+changes itself (`waitRoom`), and listens on a Unix socket959+(`cowork.Listen`); `012 serve` listens beside its rooms. `012 mcp960+--attach` is a pipe between the host's standard input and output and961+that socket, after a line each way naming the agent and the workbook.962+In the session, each connection is a seat (`cowork.Agent`, which963+implements `room.Agent`) and an `mcp.Server` whose `Options.Live` is964+that agent: `internal/mcp`'s tools stand on a `Backend` of `View`,965+`Scratch` and `Change`, and the agent's are its seat's turns, `Scratch`966+a copy taken in one, and changes go through `mcp.Live.Make`:967+968+```mermaid969+flowchart TD970+ tool[a write tool] --> make[Agent.Make, a turn]971+ make --> propose[sheet.Propose: the change on a copy; its cells and what else it does]972+ propose --> scope{Scope.Check}973+ scope -->|outside| refuse[refused, with the reason]974+ scope -->|direct edits| apply[Proposal.Apply: a step in the agent's name]975+ scope -->|otherwise| board[a Suggestion on the room's Board; Seat.Touch]976+ board --> screen[the person's frame: cells marked, the review panel]977+ screen -->|accept| apply978+ screen -->|reject| told[marked rejected]979+ apply --> notify[the agent's Notify: the suggestions resource updated]980+ told --> notify981+```982+983+The board (`cowork.Board`, a room value) holds the suggestions, the984+scope, direct edits, the grants and the agent's questions, so it's read985+and changed in turns like the workbook. A proposal of cells alone is986+applied as the cells it made on the copy, cell by cell if the person987+picks; one that does more is applied whole by running the change again988+(`sheet/proposal.go`). Questions and grants wait on the board until the989+person asked is free (`agentask.go`); a notebook run the agent is990+allowed is posted to the room's keeper, as other runs are.991+958992 ## Configuration and secrets959993 960994 `internal/config` reads one Ghostty-style file (`key = value`, `#`
M docs/contributing/testing.md +8 -2
@@ -52,7 +52,9 @@ shared, as a room of `012 serve` holds it, then has them undo their own
5252 steps in turns drawn from the seed, out of order wherever the rule5353 allows: someone can always undo, an undo redone at once gives back the5454 file it undid, and once every step is undone the workbook is back where55-it started.55+it started. A fourth author stands for the agent of live mode: its edits are56+proposals made on a copy, accepted whole, cell by cell or not at all as57+the seed draws, and its accepted steps are undone with the rest.5658 5759 The UI is tested by sending5860 Bubble Tea messages (keys, mouse, paste, window size) to the model and
@@ -105,7 +107,11 @@ restores the terminal and keeps the work; release builds have no such key.
105107 is tested as a user sees it (skipped when `ssh` isn't installed). `e2e/share_test.go` has two people106108 reach one `012 serve` that way, each in a terminal of their own, and107109 share a file; screens of a shared workbook start the others' sessions108-first (`screen.peers`).110+first (`screen.peers`). `e2e/live_test.go` runs `012 --listen` and a real111+`012 mcp --attach`, spoken to in MCP's JSON-RPC as a host would: the112+agent reads, suggests and asks, and the person accepts in the grid;113+live mode's screens attach it first (`screen.live`). Live mode's unit114+tests (`internal/cowork`) attach the SDK's client over the socket.109115 110116 `make e2e` builds libghostty-vt from source with Zig into `.deps/` on first111117 use. It is its own Go module so cgo never reaches the main binary.
M docs/contributing/ux.md +1 -0
A docs/media/live-ask-dark.png +0 -0

Binary file.

A docs/media/live-ask-light.png +0 -0

Binary file.

A docs/media/live-review-dark.png +0 -0

Binary file.

A docs/media/live-review-light.png +0 -0

Binary file.

A docs/media/live-suggestion-dark.png +0 -0

Binary file.

A docs/media/live-suggestion-light.png +0 -0

Binary file.

A docs/media/live.gif +0 -0

Binary file.

M docs/media/palette-dark.png +0 -0

Binary file.

M docs/media/palette-light.png +0 -0

Binary file.

M docs/reference/command-line.md +2 -0
@@ -14,6 +14,7 @@ like a command (`get`, `diff`) opens as `./get`.
1414 |---|---|1515 | `012 [flags] [file]` | Opens a `.012` workbook (created on saving when it doesn't exist), or imports another format: [Files](../files/README.md) |1616 | `012 [flags] -` | A table from standard input: [Pipelines](../nushell/pipelines.md) |17+| `012 [flags] --listen [file]` | Lets agents work in the session with you, attached with `012 mcp --attach`: [Live mode](../agents/live.md) |1718 | `012 [flags] --pipe [--to nuon\|json\|csv\|tsv] [--send ask\|selection\|sheet] [file]` | On quitting, sends a table to standard output: [Pipelines](../nushell/pipelines.md) |1819 | `012 nu [flags] [file]` | A nushell notebook, at its prompt: [Notebooks](../nushell/notebooks.md) |1920 | `012 serve [flags] [dir]` | Serves the sheets in a directory over SSH: [Serving over SSH](../terminal/ssh.md) |
@@ -30,6 +31,7 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
3031 | `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |3132 | `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |3233 | `012 mcp [file.012]` | Serves the workbooks in the folders open to it to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |34+| `012 mcp --attach [name\|socket]` | Joins a running 012 listening for agents, as the agent's MCP server: [Live mode](../agents/live.md#starting) |3335 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3436 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3537 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |
M docs/reference/keys.md +12 -0
M docs/terminal/ssh.md +7 -0
M e2e/harness_test.go +2 -0
A e2e/live_test.go +243 -0
@@ -0,0 +1,243 @@
1+package e2e2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "io"8+ "os"9+ "os/exec"10+ "strings"11+ "sync"12+ "testing"13+ "time"14+)15+16+// Live mode (docs/agents/live.md): 012 --listen in a terminal, and an17+// agent attached with a real 012 mcp --attach, spoken to in MCP's18+// JSON-RPC over its standard input and output as a host would.19+20+// runDir is a short folder for sessions' sockets, whose paths are21+// limited to about 100 bytes, shared by the session and its agent.22+func runDir(t *testing.T) string {23+ t.Helper()24+ d, err := os.MkdirTemp("", "o12")25+ if err != nil {26+ t.Fatal(err)27+ }28+ t.Cleanup(func() { os.RemoveAll(d) })29+ return d30+}31+32+// agent is 012 mcp --attach, as a host runs it.33+type agent struct {34+ t *testing.T35+ cmd *exec.Cmd36+ in io.WriteCloser37+ out *bufio.Reader38+ mu sync.Mutex39+ next int40+}41+42+// attach starts 012 mcp --attach target as the host named client, and43+// initializes the connection.44+func attach(t *testing.T, run, target, client string) *agent {45+ t.Helper()46+ args := []string{"mcp", "--attach"}47+ if target != "" {48+ args = append(args, target)49+ }50+ cmd := exec.Command(binPath, args...)51+ cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+run)52+ in, err := cmd.StdinPipe()53+ if err != nil {54+ t.Fatal(err)55+ }56+ out, err := cmd.StdoutPipe()57+ if err != nil {58+ t.Fatal(err)59+ }60+ var stderr strings.Builder61+ cmd.Stderr = &stderr62+ if err := cmd.Start(); err != nil {63+ t.Fatal(err)64+ }65+ a := &agent{t: t, cmd: cmd, in: in, out: bufio.NewReader(out)}66+ t.Cleanup(func() {67+ in.Close()68+ done := make(chan struct{})69+ go func() { cmd.Wait(); close(done) }()70+ select {71+ case <-done:72+ case <-time.After(5 * time.Second):73+ cmd.Process.Kill()74+ }75+ })76+ res := a.request("initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{},77+ "clientInfo": map[string]any{"name": client, "version": "1"}})78+ if res["serverInfo"] == nil {79+ t.Fatalf("initialize: %v (stderr %s)", res, stderr.String())80+ }81+ a.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})82+ return a83+}84+85+func (a *agent) send(msg map[string]any) {86+ data, _ := json.Marshal(msg)87+ if _, err := a.in.Write(append(data, '\n')); err != nil {88+ a.t.Fatal(err)89+ }90+}91+92+// request sends a request and returns its result, skipping93+// notifications; an error fails the test.94+func (a *agent) request(method string, params any) map[string]any {95+ a.t.Helper()96+ a.mu.Lock()97+ a.next++98+ id := a.next99+ a.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})100+ defer a.mu.Unlock()101+ for {102+ line, err := a.out.ReadBytes('\n')103+ if err != nil {104+ a.t.Errorf("%s: %v", method, err)105+ return nil106+ }107+ var msg struct {108+ ID *int `json:"id"`109+ Result map[string]any `json:"result"`110+ Error json.RawMessage `json:"error"`111+ }112+ if json.Unmarshal(line, &msg) != nil || msg.ID == nil || *msg.ID != id {113+ continue114+ }115+ if msg.Error != nil {116+ a.t.Errorf("%s: %s", method, msg.Error)117+ }118+ return msg.Result119+ }120+}121+122+// tool calls a tool and returns its structured result.123+func (a *agent) tool(name string, args map[string]any) map[string]any {124+ a.t.Helper()125+ res := a.request("tools/call", map[string]any{"name": name, "arguments": args})126+ if res["isError"] == true {127+ a.t.Fatalf("%s: %v", name, res["content"])128+ }129+ out, _ := res["structuredContent"].(map[string]any)130+ return out131+}132+133+// startLive starts 012 --listen on a new budget.012 with 40, 2 and134+// their sum in A1:A3, and attaches the agent, claude.135+func startLive(t *testing.T, o options) (*session, *agent) {136+ t.Helper()137+ run := runDir(t)138+ o.env = append(o.env, "XDG_RUNTIME_DIR="+run)139+ s := startWith(t, o, "--listen", "budget.012")140+ s.keys("40", "<enter>", "2", "<enter>", "=A1+A2", "<enter>", "<ctrl+home>")141+ s.waitFor("42")142+ return s, attach(t, run, "", "claude")143+}144+145+func TestLiveAgentSuggestsAndThePersonAccepts(t *testing.T) {146+ s, a := startLive(t, options{})147+ read := a.tool("read_range", map[string]any{"ref": "A1:A3"})148+ if got := fmt.Sprint(read["values"]); got != "[[40] [2] [42]]" {149+ t.Fatalf("read %s", got)150+ }151+ s.waitFor("◆ claude")152+ wrote := a.tool("write_cells", map[string]any{"entries": []map[string]any{{"ref": "A1", "input": "48"}}, "message": "round the total"})153+ if wrote["suggestion"] != 1.0 {154+ t.Fatalf("write %v", wrote)155+ }156+ s.waitFor("◇ 1 suggestion")157+ if !strings.Contains(s.screen(), "◇") || strings.Contains(s.screen(), "50") {158+ t.Fatalf("the suggestion was made, or isn't marked:\n%s", s.screen())159+ }160+ s.keys("<ctrl+alt+a>")161+ s.waitFor("A1 40 → 48")162+ s.keys("<enter>")163+ s.waitFor("Accepted all of claude's suggestion")164+ s.eventually("A3 shows 50", func() bool { return strings.Contains(s.screen(), "50") })165+ list := a.tool("suggestions", nil)166+ if got := list["suggestions"].([]any)[0].(map[string]any)["state"]; got != "accepted" {167+ t.Fatalf("suggestions %v", list)168+ }169+ if undone := a.tool("undo", nil); undone["undone"] != "set" {170+ t.Fatalf("undo %v", undone)171+ }172+ s.eventually("A3 shows 42 again", func() bool { return strings.Contains(s.screen(), "42") })173+}174+175+func TestLiveAgentAsksThePerson(t *testing.T) {176+ s, a := startLive(t, options{})177+ answer := make(chan map[string]any, 1)178+ go func() { answer <- a.tool("ask", map[string]any{"message": "Overwrite A1:A2?"}) }()179+ s.waitFor("claude asks: Overwrite A1:A2?")180+ s.keys("<enter>")181+ select {182+ case got := <-answer:183+ if got["action"] != "accept" {184+ t.Fatalf("answer %v", got)185+ }186+ case <-time.After(waitTimeout):187+ t.Fatal("no answer")188+ }189+}190+191+// startLiveScreen starts a live screen's session on the household192+// budget, listening, with claude attached.193+func startLiveScreen(t *testing.T, o options) *session {194+ t.Helper()195+ run := runDir(t)196+ o.env = append(o.env, "XDG_RUNTIME_DIR="+run)197+ s := startWith(t, o, "--listen", "budget.012")198+ s.agent = attach(t, run, "", "claude")199+ s.waitFor("◆ claude")200+ return s201+}202+203+// Golden screens of live mode: claude's suggestion marked in the grid,204+// with what it would put in the cell under the pointer on the context205+// line; the suggestions panel; and claude's question on the context206+// line.207+var liveScreens = []screen{208+ {name: "live-review", live: true, files: householdBudget, setup: func(s *session) {209+ marchSuggestion(s)210+ s.keys("<ctrl+alt+a>")211+ s.waitFor("B4 450 → 480")212+ }},213+ {name: "live-ask", live: true, files: householdBudget, setup: func(s *session) {214+ s.agent.send(map[string]any{"jsonrpc": "2.0", "id": 99, "method": "tools/call",215+ "params": map[string]any{"name": "ask", "arguments": map[string]any{"message": "Overwrite B3:B5 with March's figures?"}}})216+ s.waitFor("claude asks: Overwrite B3:B5")217+ }},218+}219+220+// marchSuggestion has claude suggest March's food and power bills, and221+// puts the pointer on the food line.222+func marchSuggestion(s *session) {223+ s.agent.tool("write_cells", map[string]any{"message": "March's bills came in higher",224+ "entries": []map[string]any{{"ref": "B4", "input": "480"}, {"ref": "B5", "input": "95"}}})225+ s.waitFor("◇ 1 suggestion")226+ s.keys("<down>", "<down>", "<down>", "<right>")227+ s.waitFor("suggests 480")228+}229+230+func init() {231+ suggestion := screen{name: "live-suggestion", live: true, files: householdBudget, setup: marchSuggestion}232+ light, hc := suggestion, suggestion233+ light.name += "-light"234+ light.opts.light = true235+ hc.name += "-high-contrast"236+ hc.opts.config = highContrastConfig237+ review, ask := liveScreens[0], liveScreens[1]238+ review.name, review.opts.light = review.name+"-light", true239+ ask.name, ask.opts.light = ask.name+"-light", true240+ screens = append(screens, suggestion, light, hc)241+ screens = append(screens, liveScreens...)242+ screens = append(screens, review, ask)243+}
M e2e/screens_test.go +6 -0
M e2e/stills_test.go +3 -0
M e2e/stillshapes_test.go +2 -0
A e2e/testdata/screens/docs-live-ask-light.html +10 -0
@@ -0,0 +1,10 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> Household budget3+◆ claude asks: Overwrite B3:B5 with… <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> Yes <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">N</span><span style="background:var(--vt-palette-7)"> </span> No <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> Cancel4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Food 4509+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power 9010+Overwrite B3:B5 with March&#39;s figures?</pre>\ No newline at end of file
A e2e/testdata/screens/docs-live-ask.html +10 -0
@@ -0,0 +1,10 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+◆ claude asks: Overwrite B3:B5 with… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Yes <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">N</span><span style="background:var(--vt-palette-8)"> </span> No <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food 4509+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+Overwrite B3:B5 with March&#39;s figures?</pre>\ No newline at end of file
A e2e/testdata/screens/docs-live-review-light.html +12 -0
@@ -0,0 +1,12 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">REVIEW</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B4</span><span style="background:var(--vt-palette-7)"> </span> 4503+4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Suggestions</span> <span style="color:var(--vt-palette-8)">────────────────────────────────────────────┐</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">claude:</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">March&#39;s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">bills</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">came</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">in</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">higher</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells)</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">✓</span><span style="background:var(--vt-palette-7)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">✗</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 1200 <span style="color:var(--vt-palette-8)">│</span> ◇ B4 450 → 480 <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> ◇ B5 90 → 95 <span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> 90 <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">waiting</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 174011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+claude: March&#39;s bills came in higher <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">R</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">reject</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">all</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/docs-live-review.html +12 -0
@@ -0,0 +1,12 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">REVIEW</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B4</span><span style="background:var(--vt-palette-8)"> </span> 4503+4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Suggestions</span> <span style="color:var(--vt-palette-8)">────────────────────────────────────────────┐</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">claude:</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">March&#39;s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">bills</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">came</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">in</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">higher</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells)</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">✓</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">✗</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 1200 <span style="color:var(--vt-palette-8)">│</span> ◇ B4 450 → 480 <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> ◇ B5 90 → 95 <span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> 90 <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">waiting</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 174011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+claude: March&#39;s bills came in higher <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">reject</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">all</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/docs-live-suggestion-light.html +12 -0
@@ -0,0 +1,12 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B4</span><span style="background:var(--vt-palette-7)"> </span> 4503+<span style="color:var(--vt-palette-5);font-weight:bold">◇</span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-0)">suggests</span> <span style="font-weight:bold">480</span><span style="color:var(--vt-palette-0)">,</span> <span style="color:var(--vt-palette-0)">now</span> <span style="color:var(--vt-palette-0)">450:</span> <span style="color:var(--vt-palette-0)">March&#39;s</span> <span style="color:var(--vt-palette-0)">bills</span> <span style="color:var(--vt-palette-0)">came</span> <span style="color:var(--vt-palette-0)">i…</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">review</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> 9010+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 174011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> <span style="color:var(--vt-palette-5);font-weight:bold">1</span> <span style="color:var(--vt-palette-5);font-weight:bold">suggestion</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span></pre>\ No newline at end of file
A e2e/testdata/screens/docs-live-suggestion.html +12 -0
@@ -0,0 +1,12 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B4</span><span style="background:var(--vt-palette-8)"> </span> 4503+<span style="color:var(--vt-palette-13);font-weight:bold">◇</span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-8)">suggests</span> <span style="font-weight:bold">480</span><span style="color:var(--vt-palette-8)">,</span> <span style="color:var(--vt-palette-8)">now</span> <span style="color:var(--vt-palette-8)">450:</span> <span style="color:var(--vt-palette-8)">March&#39;s</span> <span style="color:var(--vt-palette-8)">bills</span> <span style="color:var(--vt-palette-8)">came</span> <span style="color:var(--vt-palette-8)">i…</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">review</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 174011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> <span style="color:var(--vt-palette-13);font-weight:bold">1</span> <span style="color:var(--vt-palette-13);font-weight:bold">suggestion</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span></pre>\ No newline at end of file
M e2e/testdata/screens/docs-palette-light.html +1 -1
@@ -14,5 +14,5 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">230</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">231</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 Start a new, empty sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/docs-palette.html +1 -1
@@ -14,5 +14,5 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 Start a new, empty sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-light.html +1 -1
@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">132</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">scroll</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-narrow.html +1 -1
@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</span> <span style="color:var(--vt-palette-8)">─┘</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help-wide.html +29 -29
@@ -11,35 +11,35 @@
1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">File</span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Save <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Sheets</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Show it; double-click renames <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Click</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">a</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Next sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+PgDn</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Right</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Edit</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Previous sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+PgUp</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Left</span><span style="background:var(--vt-palette-8)"> </span> Undo <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F11</span><span style="background:var(--vt-palette-8)"> </span> Redo <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Y</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+K</span><span style="background:var(--vt-palette-8)"> </span> Cut <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+X</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Copy <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+C</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>21-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Selecting</span> Paste <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+V</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Extend the selection <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+arrows</span><span style="background:var(--vt-palette-8)"> </span> Paste values only <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+V</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>23-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Extend to the edge of the data <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+arrows</span><span style="background:var(--vt-palette-8)"> </span> Fill down <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+D</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>24-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select by dragging or Shift+click <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mouse</span><span style="background:var(--vt-palette-8)"> </span> Fill right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>25-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Find <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+F</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>26-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> Find and replace <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+H</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>27-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> Clear <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Del</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Backspace</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </span> Delete rows or columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>29-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>30-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">26</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Entering</span> <span style="font-weight:bold">data</span> <span style="font-weight:bold">Insert</span> <span style="color:var(--vt-palette-8)">│</span>31-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">27</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace the cell; = starts a formula <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Type</span><span style="background:var(--vt-palette-8)"> </span> Insert rows or columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>32-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">28</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Accept and move down, right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> Note <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>33-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">29</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Pick cells for a formula <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>34-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">30</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cancel the entry <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Format</span> <span style="color:var(--vt-palette-8)">│</span>35-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">31</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Edit cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Number <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>36-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">32</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Percent <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>37-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">33</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Macros</span> Scientific <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>38-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">34</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Run the macro with that shortcut <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+Shift+0-9</span><span style="background:var(--vt-palette-8)"> </span> Currency <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>39-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">35</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Stop a macro while it runs <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Date <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>40-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Time <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>41-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Notebooks</span> Bold <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+B</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>42-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move between cells and outputs <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">j</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">k</span><span style="background:var(--vt-palette-8)"> </span> Italic <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+I</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Sheets</span> Review suggestions <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Show it; double-click renames <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Click</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">a</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">tab</span><span style="background:var(--vt-palette-8)"> </span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Next sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+PgDn</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Right</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Previous sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+PgUp</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Left</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Edit</span> <span style="color:var(--vt-palette-8)">│</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> New sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F11</span><span style="background:var(--vt-palette-8)"> </span> Undo <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Go to sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Alt+Shift+K</span><span style="background:var(--vt-palette-8)"> </span> Redo <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+Z</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Y</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cut <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+X</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Selecting</span> Copy <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+C</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Extend the selection <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+arrows</span><span style="background:var(--vt-palette-8)"> </span> Paste <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+V</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Extend to the edge of the data <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+arrows</span><span style="background:var(--vt-palette-8)"> </span> Paste values only <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+V</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select by dragging or Shift+click <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Mouse</span><span style="background:var(--vt-palette-8)"> </span> Fill down <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+D</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Fill right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> Find <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+F</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> Find and replace <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+H</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select rows <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Space</span><span style="background:var(--vt-palette-8)"> </span> Clear <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Del</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Backspace</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>30+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">26</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Entering</span> <span style="font-weight:bold">data</span> <span style="color:var(--vt-palette-8)">│</span>31+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">27</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Replace the cell; = starts a formula <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Type</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Insert</span> <span style="color:var(--vt-palette-8)">│</span>32+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">28</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Accept and move down, right <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> Insert rows or columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+=</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>33+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">29</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Pick cells for a formula <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Arrows</span><span style="background:var(--vt-palette-8)"> </span> Note <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>34+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">30</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Cancel the entry <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>35+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">31</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Edit cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F2</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="font-weight:bold">Format</span> <span style="color:var(--vt-palette-8)">│</span>36+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">32</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Number <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+1</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>37+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">33</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Macros</span> Percent <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>38+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">34</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Run the macro with that shortcut <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+Shift+0-9</span><span style="background:var(--vt-palette-8)"> </span> Scientific <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+6</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>39+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">35</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Stop a macro while it runs <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Currency <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>40+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Date <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+3</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>41+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Notebooks</span> Time <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>42+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move between cells and outputs <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">j</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">k</span><span style="background:var(--vt-palette-8)"> </span> Bold <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+B</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4343 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">66</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/help.html +1 -1
@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">132</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/live-ask-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> Household budget3+◆ claude asks: Overwrite B3:B5 with March&#39;s figures? <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> Yes <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">N</span><span style="background:var(--vt-palette-7)"> </span> No <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> Cancel4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Food 4509+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power 9010+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 174011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+Overwrite B3:B5 with March&#39;s figures?</pre>\ No newline at end of file
A e2e/testdata/screens/live-ask.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+◆ claude asks: Overwrite B3:B5 with March&#39;s figures? <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Yes <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">N</span><span style="background:var(--vt-palette-8)"> </span> No <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food 4509+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 174011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Overwrite B3:B5 with March&#39;s figures?</pre>\ No newline at end of file
A e2e/testdata/screens/live-review-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">REVIEW</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B4</span><span style="background:var(--vt-palette-7)"> </span> 4503+4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Suggestions</span> <span style="color:var(--vt-palette-8)">────────────────────────────────────────────┐</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">claude:</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">March&#39;s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">bills</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">came</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">in</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">higher</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells)</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">✓</span><span style="background:var(--vt-palette-7)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">✗</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 1200 <span style="color:var(--vt-palette-8)">│</span> ◇ B4 450 → 480 <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> ◇ B5 90 → 95 <span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> 90 <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">waiting</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 174011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+claude: March&#39;s bills came in higher <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">R</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">reject</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">all</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/live-review.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">REVIEW</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B4</span><span style="background:var(--vt-palette-8)"> </span> 4503+4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Suggestions</span> <span style="color:var(--vt-palette-8)">────────────────────────────────────────────┐</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">claude:</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">March&#39;s</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">bills</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">came</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">in</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">higher</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(2</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">cells)</span><span style="background:var(--vt-palette-6)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">✓</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">✗</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 1200 <span style="color:var(--vt-palette-8)">│</span> ◇ B4 450 → 480 <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">│</span> ◇ B5 90 → 95 <span style="color:var(--vt-palette-8)">│</span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> 90 <span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">waiting</span> <span style="color:var(--vt-palette-8)">─┘</span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 174011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+claude: March&#39;s bills came in higher <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">R</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">reject</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">all</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/live-suggestion-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">450</span><span style="background:#000000"> </span>3+<span style="color:#ffa0ff;background:#000000;font-weight:bold">◇</span><span style="background:#000000"> </span><span style="background:#40e040"> </span><span style="color:#000000;background:#40e040">◆</span><span style="background:#40e040"> </span><span style="color:#000000;background:#40e040">claude</span><span style="background:#40e040"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">suggests</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">480</span><span style="color:#959595;background:#000000">,</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">now</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">450:</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">March&#39;s</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">bills</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">came</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">higher</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Alt+A</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">review</span><span style="background:#000000"> </span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A</span><span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">B</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Household</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">budget</span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>7+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Rent</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1200</span><span style="background:#000000"> </span>8+<span style="color:#000000;background:#40e040">◆</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">4</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Food</span><span style="background:#000000"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">◇</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">450</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>9+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Power</span><span style="background:#000000"> </span><span style="color:#ffa0ff;background:#000000;font-weight:bold">◇</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">90</span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Total</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1740</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Sheet1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">budget.012</span><span style="background:#1f1f1f"> </span><span style="background:#40e040"> </span><span style="color:#000000;background:#40e040">◆</span><span style="background:#40e040"> </span><span style="color:#000000;background:#40e040">claude</span><span style="background:#40e040"> </span><span style="background:#1f1f1f"> </span><span style="color:#ffa0ff;background:#1f1f1f;font-weight:bold">◇</span><span style="background:#1f1f1f"> </span><span style="color:#ffa0ff;background:#1f1f1f;font-weight:bold">1</span><span style="background:#1f1f1f"> </span><span style="color:#ffa0ff;background:#1f1f1f;font-weight:bold">suggestion</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span></pre>\ No newline at end of file
A e2e/testdata/screens/live-suggestion-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B4</span><span style="background:var(--vt-palette-7)"> </span> 4503+<span style="color:var(--vt-palette-5);font-weight:bold">◇</span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-0)">suggests</span> <span style="font-weight:bold">480</span><span style="color:var(--vt-palette-0)">,</span> <span style="color:var(--vt-palette-0)">now</span> <span style="color:var(--vt-palette-0)">450:</span> <span style="color:var(--vt-palette-0)">March&#39;s</span> <span style="color:var(--vt-palette-0)">bills</span> <span style="color:var(--vt-palette-0)">came</span> <span style="color:var(--vt-palette-0)">in</span> <span style="color:var(--vt-palette-0)">higher</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">review</span>4+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> 9010+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 174011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span>12+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-5);font-weight:bold">◇</span> <span style="color:var(--vt-palette-5);font-weight:bold">1</span> <span style="color:var(--vt-palette-5);font-weight:bold">suggestion</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span></pre>\ No newline at end of file
A e2e/testdata/screens/live-suggestion.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B4</span><span style="background:var(--vt-palette-8)"> </span> 4503+<span style="color:var(--vt-palette-13);font-weight:bold">◇</span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-8)">suggests</span> <span style="font-weight:bold">480</span><span style="color:var(--vt-palette-8)">,</span> <span style="color:var(--vt-palette-8)">now</span> <span style="color:var(--vt-palette-8)">450:</span> <span style="color:var(--vt-palette-8)">March&#39;s</span> <span style="color:var(--vt-palette-8)">bills</span> <span style="color:var(--vt-palette-8)">came</span> <span style="color:var(--vt-palette-8)">in</span> <span style="color:var(--vt-palette-8)">higher</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">review</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">◇</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">450</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 174011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">◆</span><span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">claude</span><span style="background:var(--vt-palette-2)"> </span> <span style="color:var(--vt-palette-13);font-weight:bold">◇</span> <span style="color:var(--vt-palette-13);font-weight:bold">1</span> <span style="color:var(--vt-palette-13);font-weight:bold">suggestion</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span></pre>\ No newline at end of file
M e2e/testdata/screens/menu-download.html +15 -15
@@ -1,19 +1,19 @@
11 <pre class="screen"><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile</span><span style="background:var(--vt-palette-6)"> </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2-<span style="color:var(--vt-palette-8)">┌─────────────────────┐</span>3-<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4-<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5-<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span> 20266-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>7-<span style="color:var(--vt-palette-8)">│</span> Save <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>508-<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│┌──────────────────────────────────┐</span>9-<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Download</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Comma-separated</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">values</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(.csv)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>10-<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Tab-separated values (.tsv) <span style="color:var(--vt-palette-8)">│</span>11-<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">││</span> Microsoft Excel (.xlsx) <span style="color:var(--vt-palette-8)">│</span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> SQLite database (.sqlite) <span style="color:var(--vt-palette-8)">│</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> JSON list of records (.json) <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└─────────────────────┘│</span> Nushell table (.nuon) <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Web page (.html) <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────┘</span>2+<span style="color:var(--vt-palette-8)">┌────────────────────────────┐</span>3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>7+<span style="color:var(--vt-palette-8)">│</span> Save <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│┌──────────────────────────────────┐</span>9+<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Download</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Comma-separated</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">values</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">(.csv)</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>10+<span style="color:var(--vt-palette-8)">├────────────────────────────┤│</span> Tab-separated values (.tsv) <span style="color:var(--vt-palette-8)">│</span>11+<span style="color:var(--vt-palette-8)">│</span> Invite an agent <span style="color:var(--vt-palette-8)">││</span> Microsoft Excel (.xlsx) <span style="color:var(--vt-palette-8)">│</span>12+<span style="color:var(--vt-palette-8)">├────────────────────────────┤│</span> SQLite database (.sqlite) <span style="color:var(--vt-palette-8)">│</span>13+<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">││</span> JSON list of records (.json) <span style="color:var(--vt-palette-8)">│</span>14+<span style="color:var(--vt-palette-8)">├────────────────────────────┤│</span> Nushell table (.nuon) <span style="color:var(--vt-palette-8)">│</span>15+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> Web page (.html) <span style="color:var(--vt-palette-8)">│</span>16+<span style="color:var(--vt-palette-8)">└────────────────────────────┘└──────────────────────────────────┘</span>1717 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/menu-light.html +15 -15
@@ -1,19 +1,19 @@
11 <pre class="screen"><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile</span><span style="background:var(--vt-palette-6)"> </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2-<span style="color:var(--vt-palette-8)">┌─────────────────────┐</span>3-<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4-<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+O</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5-<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span> 20266-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>7-<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Save</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+S</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>508-<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>.49-<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>9610-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span><span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">!</span>11-<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">.4</span><span style="background:var(--vt-palette-6)"> </span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Q</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└─────────────────────┘</span>15-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>2+<span style="color:var(--vt-palette-8)">┌────────────────────────────┐</span>3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+O</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>7+<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Save</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+S</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>10+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>11+<span style="color:var(--vt-palette-8)">│</span> Invite an agent <span style="color:var(--vt-palette-8)">│</span>12+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>13+<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">│</span>14+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>15+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Q</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="color:var(--vt-palette-8)">└────────────────────────────┘</span>1717 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/menu-settings.html +21 -21
@@ -1,25 +1,25 @@
11 <pre class="screen"><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile</span><span style="background:var(--vt-palette-6)"> </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2-<span style="color:var(--vt-palette-8)">┌─────────────────────┐</span>3-<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4-<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5-<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span>356-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>107-<span style="color:var(--vt-palette-8)">│</span> Save <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>208-<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>009-<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>10-<span style="color:var(--vt-palette-8)">├─────────────────────┤┌────────────────────────┐</span>11-<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Settings</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Decimal</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">arithmetic</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Locale <span style="color:var(--vt-palette-8)">│</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> Vim keys <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└─────────────────────┘├────────────────────────┤</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Theme <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> JEV API key <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────┤</span>18-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open config file <span style="color:var(--vt-palette-8)">│</span>19-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reload config <span style="color:var(--vt-palette-8)">│</span>20-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────┘</span>21-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>2+<span style="color:var(--vt-palette-8)">┌────────────────────────────┐</span>3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>7+<span style="color:var(--vt-palette-8)">│</span> Save <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+S</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>10+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>11+<span style="color:var(--vt-palette-8)">│</span> Invite an agent <span style="color:var(--vt-palette-8)">│</span>12+<span style="color:var(--vt-palette-8)">├────────────────────────────┤┌────────────────────────┐</span>13+<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Settings</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">›</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">││</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Decimal</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">arithmetic</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">✓</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>14+<span style="color:var(--vt-palette-8)">├────────────────────────────┤│</span> Locale <span style="color:var(--vt-palette-8)">│</span>15+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> Vim keys <span style="color:var(--vt-palette-8)">│</span>16+<span style="color:var(--vt-palette-8)">└────────────────────────────┘├────────────────────────┤</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Theme <span style="color:var(--vt-palette-8)">│</span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> JEV API key <span style="color:var(--vt-palette-8)">│</span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">├────────────────────────┤</span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Open config file <span style="color:var(--vt-palette-8)">│</span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reload config <span style="color:var(--vt-palette-8)">│</span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────┘</span>2323 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>2424 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>2525 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/menu.html +15 -15
@@ -1,19 +1,19 @@
11 <pre class="screen"><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile</span><span style="background:var(--vt-palette-6)"> </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2-<span style="color:var(--vt-palette-8)">┌─────────────────────┐</span>3-<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4-<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5-<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span> 20266-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>7-<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Save</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+S</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>508-<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>.49-<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>9610-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span><span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">!</span>11-<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">.4</span><span style="background:var(--vt-palette-6)"> </span>12-<span style="color:var(--vt-palette-8)">├─────────────────────┤</span>13-<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└─────────────────────┘</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>2+<span style="color:var(--vt-palette-8)">┌────────────────────────────┐</span>3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+O</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="color:var(--vt-palette-8)">│</span> Import <span style="color:var(--vt-palette-8)">│</span>6+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>7+<span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Save</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Ctrl+S</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>8+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>9+<span style="color:var(--vt-palette-8)">│</span> Download › <span style="color:var(--vt-palette-8)">│</span>10+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>11+<span style="color:var(--vt-palette-8)">│</span> Invite an agent <span style="color:var(--vt-palette-8)">│</span>12+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>13+<span style="color:var(--vt-palette-8)">│</span> Settings › <span style="color:var(--vt-palette-8)">│</span>14+<span style="color:var(--vt-palette-8)">├────────────────────────────┤</span>15+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>16+<span style="color:var(--vt-palette-8)">└────────────────────────────┘</span>1717 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-narrow.html +1 -1
@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Read</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">source</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">68</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">68</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Code</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">y</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Link a <span style="color:var(--vt-palette-3);font-weight:bold">s</span>ourc<span style="color:var(--vt-palette-3);font-weight:bold">e</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-3);font-weight:bold">L</span><span style="color:var(--vt-palette-8)">inked</span> <span style="color:var(--vt-palette-8)">file</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">63</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">63</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">230</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">231</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/theme-classic-narrow-menu.html +14 -14
@@ -1,16 +1,16 @@
11 <pre class="screen"><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa;text-decoration:underline">F</span><span style="color:#000000;background:#00aaaa">ile</span><span style="background:#00aaaa"> </span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1fb4">dit</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1fb4">iew</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1fb4">nsert</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">F</span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1fb4">rmat</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1fb4">ata</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1fb4">elp</span><span style="background:#1f1fb4"> </span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa;font-weight:bold">MENU</span><span style="background:#00aaaa"> </span>2-<span style="color:#787878;background:#0000aa">┌─────────────────────┐</span><span style="background:#0000aa"> </span>3-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">New</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>4-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Open</span><span style="background:#0000aa"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Ctrl+O</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#00aaaa"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">C</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">D</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">E</span><span style="background:#555555"> </span>5-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Import</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">2026</span><span style="background:#0000aa"> </span>6-<span style="color:#787878;background:#0000aa">├─────────────────────┤</span><span style="background:#0000aa"> </span>7-<span style="color:#787878;background:#0000aa">│</span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa">Save</span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa">Ctrl+S</span><span style="background:#00aaaa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="color:#ffffff;background:#0000aa">50</span><span style="background:#0000aa"> </span>8-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Save</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">as</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="color:#ffffff;background:#0000aa">.4</span><span style="background:#0000aa"> </span>9-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Download</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">›</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="color:#ffffff;background:#0000aa">96</span><span style="background:#0000aa"> </span>10-<span style="color:#787878;background:#0000aa">├─────────────────────┤</span><span style="color:#d47f7f;background:#0000aa;text-decoration:underline wavy #aa0000">!</span><span style="background:#0000aa"> </span>11-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Settings</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">›</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="color:#000000;background:#00aaaa">.4</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span>12-<span style="color:#787878;background:#0000aa">├─────────────────────┤</span><span style="background:#0000aa"> </span>13-<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Quit</span><span style="background:#0000aa"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Ctrl+Q</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>14-<span style="color:#787878;background:#0000aa">└─────────────────────┘</span><span style="background:#0000aa"> </span>15-<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">11</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>2+<span style="color:#787878;background:#0000aa">┌────────────────────────────┐</span><span style="background:#0000aa"> </span>3+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">New</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>4+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Open</span><span style="background:#0000aa"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Ctrl+O</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="color:#ffffff;background:#555555">C</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">D</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">E</span><span style="background:#555555"> </span>5+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Import</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>6+<span style="color:#787878;background:#0000aa">├────────────────────────────┤</span><span style="background:#0000aa"> </span>7+<span style="color:#787878;background:#0000aa">│</span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa">Save</span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa">Ctrl+S</span><span style="background:#00aaaa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>8+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Save</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">as</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>9+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Download</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">›</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>10+<span style="color:#787878;background:#0000aa">├────────────────────────────┤</span><span style="background:#0000aa"> </span>11+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Invite</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">an</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">agent</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>12+<span style="color:#787878;background:#0000aa">├────────────────────────────┤</span><span style="background:#0000aa"> </span>13+<span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Settings</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">›</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">│</span><span style="background:#0000aa"> </span>14+<span style="color:#787878;background:#0000aa">├────────────────────────────┤</span><span style="background:#0000aa"> </span>15+<span style="color:#787878;background:#0000aa">└────────────────────────</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">▼</span><span style="background:#0000aa"> </span><span style="color:#787878;background:#0000aa">─┘</span><span style="background:#0000aa"> </span>1616 <span style="color:#ffffff;background:#1f1fb4">Save</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">the</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">sheet</span><span style="background:#1f1fb4"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Arrows</span><span style="background:#555555"> </span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">move</span><span style="background:#1f1fb4"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Enter</span><span style="background:#555555"> </span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">choose</span><span style="background:#1f1fb4"> </span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">Esc</span><span style="background:#555555"> </span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/theme-high-contrast-menu.html +15 -15
@@ -1,19 +1,19 @@
11 <pre class="screen"><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;text-decoration:underline">F</span><span style="color:#000000;background:#40e0ff">ile</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">MENU</span><span style="background:#40e0ff"> </span>2-<span style="color:#757575;background:#000000">┌─────────────────────┐</span><span style="background:#000000"> </span>3-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">New</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>4-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Open</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+O</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Import</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2026</span><span style="background:#000000"> </span>6-<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>7-<span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Save</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Ctrl+S</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">50</span><span style="background:#000000"> </span>8-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Save</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">as</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">.4</span><span style="background:#000000"> </span>9-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Download</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">96</span><span style="background:#000000"> </span>10-<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="color:#ff8080;background:#000000;text-decoration:underline wavy #ff8080">!</span><span style="background:#000000"> </span>11-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Settings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#000000;background:#40e0ff">.4</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>12-<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>13-<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Quit</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Q</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>14-<span style="color:#757575;background:#000000">└─────────────────────┘</span><span style="background:#000000"> </span>15-<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16-<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>2+<span style="color:#757575;background:#000000">┌────────────────────────────┐</span><span style="background:#000000"> </span>3+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">New</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>4+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Open</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+O</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Import</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">├────────────────────────────┤</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Save</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Ctrl+S</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>8+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Save</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">as</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>9+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Download</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">├────────────────────────────┤</span><span style="background:#000000"> </span>11+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Invite</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">an</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">agent</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>12+<span style="color:#757575;background:#000000">├────────────────────────────┤</span><span style="background:#000000"> </span>13+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Settings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>14+<span style="color:#757575;background:#000000">├────────────────────────────┤</span><span style="background:#000000"> </span>15+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Quit</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Q</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>16+<span style="color:#757575;background:#000000">└────────────────────────────┘</span><span style="background:#000000"> </span>1717 <span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>1818 <span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>1919 <span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>
M e2e/testdata/screens/theme-light-menu.html +15 -15
@@ -1,19 +1,19 @@
11 <pre class="screen"><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;text-decoration:underline">F</span><span style="color:#1c1c24;background:#179299">ile</span><span style="background:#179299"> </span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">E</span><span style="color:#4c4f69;background:#dadde4">dit</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">V</span><span style="color:#4c4f69;background:#dadde4">iew</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">I</span><span style="color:#4c4f69;background:#dadde4">nsert</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4">F</span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">o</span><span style="color:#4c4f69;background:#dadde4">rmat</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">D</span><span style="color:#4c4f69;background:#dadde4">ata</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4;text-decoration:underline">H</span><span style="color:#4c4f69;background:#dadde4">elp</span><span style="background:#dadde4"> </span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299;font-weight:bold">MENU</span><span style="background:#179299"> </span>2-<span style="color:#6c6f85;background:#eff1f5">┌─────────────────────┐</span><span style="background:#eff1f5"> </span>3-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">New</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>4-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Open</span><span style="background:#eff1f5"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+O</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#179299"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">C</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">D</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">E</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">F</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">G</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">H</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">I</span><span style="background:#acb0be"> </span>5-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Import</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">2026</span><span style="background:#eff1f5"> </span>6-<span style="color:#6c6f85;background:#eff1f5">├─────────────────────┤</span><span style="background:#eff1f5"> </span>7-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">Save</span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">Ctrl+S</span><span style="background:#179299"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="color:#4c4f69;background:#eff1f5">50</span><span style="background:#eff1f5"> </span>8-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Save</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">as</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="color:#4c4f69;background:#eff1f5">.4</span><span style="background:#eff1f5"> </span>9-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Download</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">›</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="color:#4c4f69;background:#eff1f5">96</span><span style="background:#eff1f5"> </span>10-<span style="color:#6c6f85;background:#eff1f5">├─────────────────────┤</span><span style="color:#d20f39;background:#eff1f5;text-decoration:underline wavy #d20f39">!</span><span style="background:#eff1f5"> </span>11-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Settings</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">›</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="color:#1c1c24;background:#179299">.4</span><span style="background:#179299"> </span><span style="background:#eff1f5"> </span>12-<span style="color:#6c6f85;background:#eff1f5">├─────────────────────┤</span><span style="background:#eff1f5"> </span>13-<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Quit</span><span style="background:#eff1f5"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+Q</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>14-<span style="color:#6c6f85;background:#eff1f5">└─────────────────────┘</span><span style="background:#eff1f5"> </span>15-<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">11</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>16-<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">12</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>2+<span style="color:#6c6f85;background:#eff1f5">┌────────────────────────────┐</span><span style="background:#eff1f5"> </span>3+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">New</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>4+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Open</span><span style="background:#eff1f5"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+O</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="color:#414354;background:#acb0be">C</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">D</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">E</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">F</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">G</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">H</span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">I</span><span style="background:#acb0be"> </span>5+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Import</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>6+<span style="color:#6c6f85;background:#eff1f5">├────────────────────────────┤</span><span style="background:#eff1f5"> </span>7+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">Save</span><span style="background:#179299"> </span><span style="color:#1c1c24;background:#179299">Ctrl+S</span><span style="background:#179299"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>8+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Save</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">as</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>9+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Download</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">›</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>10+<span style="color:#6c6f85;background:#eff1f5">├────────────────────────────┤</span><span style="background:#eff1f5"> </span>11+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Invite</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">an</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">agent</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>12+<span style="color:#6c6f85;background:#eff1f5">├────────────────────────────┤</span><span style="background:#eff1f5"> </span>13+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Settings</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">›</span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>14+<span style="color:#6c6f85;background:#eff1f5">├────────────────────────────┤</span><span style="background:#eff1f5"> </span>15+<span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span><span style="color:#4c4f69;background:#eff1f5">Quit</span><span style="background:#eff1f5"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Ctrl+Q</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span><span style="color:#6c6f85;background:#eff1f5">│</span><span style="background:#eff1f5"> </span>16+<span style="color:#6c6f85;background:#eff1f5">└────────────────────────────┘</span><span style="background:#eff1f5"> </span>1717 <span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">13</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>1818 <span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">14</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>1919 <span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">15</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>
A internal/cowork/agent.go +363 -0
@@ -0,0 +1,363 @@
1+package cowork2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "path/filepath"9+ "strings"10+ "sync"11+12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/diff"15+ "github.com/FelineStateMachine/012/internal/headless"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/room"18+ "github.com/FelineStateMachine/012/internal/sheet"19+)20+21+// Agent is an attached agent's side of the room: its seat, and the22+// mcp.Live its tools stand on. Every call is a turn of its seat, so its23+// reads and changes take their place among the people's, and its steps24+// are its own.25+type Agent struct {26+ seat *room.Seat27+ book string // the workbook's name, for the tools28+29+ mu sync.Mutex30+ notices []string // news of its suggestions, for its next write31+}32+33+var _ mcp.Live = (*Agent)(nil)34+35+// NewAgent is the agent sitting at seat.36+func NewAgent(seat *room.Seat) *Agent {37+ a := &Agent{seat: seat}38+ seat.Do(func(*sheet.Workbook) {39+ name := seat.Saved().Name40+ a.book = strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))41+ })42+ if a.book == "" || a.book == "." {43+ a.book = "Untitled"44+ }45+ return a46+}47+48+// Name is the workbook's.49+func (a *Agent) Name() string { return a.book }50+51+// Seat is the agent's seat.52+func (a *Agent) Seat() *room.Seat { return a.seat }53+54+// View runs fn on the room's workbook in a turn.55+func (a *Agent) View(_ context.Context, fn func(*sheet.Workbook) error) error {56+ var err error57+ a.seat.Do(func(w *sheet.Workbook) { err = fn(w) })58+ return err59+}60+61+// Scratch runs fn on a copy of the workbook taken in a turn.62+func (a *Agent) Scratch(_ context.Context, fn func(*sheet.Workbook) error) error {63+ var data bytes.Buffer64+ var err error65+ a.seat.Do(func(w *sheet.Workbook) { err = w.Write(&data) })66+ if err != nil {67+ return err68+ }69+ s, err := sheet.Read(&data)70+ if err != nil {71+ return err72+ }73+ return fn(s.Book())74+}75+76+// Change is Make without a message.77+func (a *Agent) Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error) {78+ made, err := a.Make(ctx, mcp.Making{Label: label, DryRun: dryRun, Fn: fn})79+ return made.Changes, err80+}81+82+// Make proposes the change on a copy of the workbook, checks it against83+// the scope, and then suggests it, or with direct edits allowed makes84+// it as the agent's step; a formula asking JEV made directly asks the85+// person first.86+func (a *Agent) Make(ctx context.Context, mk mcp.Making) (mcp.Made, error) {87+ var made mcp.Made88+ var p *sheet.Proposal89+ var err error90+ needJEV := false91+ a.seat.Do(func(w *sheet.Workbook) {92+ b := BoardOf(a.seat)93+ if !a.seat.Writing() {94+ err = fmt.Errorf("%s writes here and the others follow: nobody else can change the workbook", a.seat.Writer())95+ return96+ }97+ if p, err = sheet.Propose(w, mk.Label, mk.Fn); err != nil || p.Empty() {98+ return99+ }100+ if err = b.Scope.Check(p); err != nil {101+ return102+ }103+ if made.Changes, err = changesOf(w, p.Result()); err != nil || mk.DryRun {104+ return105+ }106+ switch {107+ case !b.Direct:108+ made.Suggestion = b.Add(&Suggestion{Author: a.seat.ID(), Agent: a.seat.Name(), Color: a.seat.Color(), Message: mk.Message, Proposal: p})109+ a.seat.Touch()110+ case p.Remote && !b.Grants.JEV:111+ needJEV = true112+ return113+ default:114+ err = p.Apply(w, nil)115+ made.Applied = err == nil116+ }117+ a.point(w, p)118+ })119+ if needJEV {120+ err = a.grant(ctx, "jev", a.seat.Name()+" enters formulas that ask JEV, over the network, with your key")121+ if err == nil {122+ a.seat.Do(func(w *sheet.Workbook) {123+ err = p.Apply(w, nil)124+ made.Applied = err == nil125+ a.point(w, p)126+ })127+ }128+ }129+ made.Notices = a.takeNotices()130+ return made, err131+}132+133+// changesOf is what the copy changes of w, as 012 diff lists it.134+func changesOf(w, copy *sheet.Workbook) ([]diff.Change, error) {135+ var before, after bytes.Buffer136+ if err := w.Write(&before); err != nil {137+ return nil, err138+ }139+ if err := copy.Write(&after); err != nil {140+ return nil, err141+ }142+ x, err := diff.Read(before.Bytes())143+ if err != nil {144+ return nil, err145+ }146+ y, err := diff.Read(after.Bytes())147+ if err != nil {148+ return nil, err149+ }150+ return diff.Compare(x, y), nil151+}152+153+// point moves the agent's pointer to the cells a proposal sets.154+func (a *Agent) point(w *sheet.Workbook, p *sheet.Proposal) {155+ if len(p.Cells) == 0 {156+ return157+ }158+ first := p.Cells[0]159+ s := w.Lookup(first.Sheet)160+ if s == nil {161+ return162+ }163+ r := sheet.Rect{From: first.At, To: first.At}164+ for _, c := range p.Cells {165+ if c.Sheet == first.Sheet {166+ r.From.Row, r.From.Col = min(r.From.Row, c.At.Row), min(r.From.Col, c.At.Col)167+ r.To.Row, r.To.Col = max(r.To.Row, c.At.Row), max(r.To.Col, c.At.Col)168+ }169+ }170+ a.seat.Move(room.Presence{Sheet: s, Cursor: first.At, Selection: r})171+}172+173+// Focus moves the agent's pointer to ref.174+func (a *Agent) Focus(_ context.Context, ref string) (string, error) {175+ var at string176+ var err error177+ a.seat.Do(func(w *sheet.Workbook) {178+ var t headless.Target179+ if t, err = headless.Resolve(w, ref); err != nil {180+ return181+ }182+ scope := BoardOf(a.seat).Scope183+ if !scope.Allows(t.Sheet, t.Range) {184+ err = fmt.Errorf("%s is outside what the person let you work on (%s)", ref, scope)185+ return186+ }187+ a.seat.Move(room.Presence{Sheet: t.Sheet, Cursor: t.Range.From, Selection: t.Range})188+ at = sheet.Qualified(t.Sheet.Name(), t.Range)189+ })190+ return at, err191+}192+193+// Undo takes back the agent's latest step.194+func (a *Agent) Undo(context.Context) (string, error) {195+ var label string196+ var err error197+ a.seat.Do(func(w *sheet.Workbook) {198+ switch b, blocked := w.UndoBlocked(); {199+ case !w.CanUndo():200+ err = errors.New("you have no change of yours to undo: a suggestion is one once the person accepts it; the suggestions tool lists them")201+ case blocked:202+ who := "someone who left"203+ if p, ok := a.seat.Peer(b.Author); ok {204+ who = p.Name205+ }206+ err = fmt.Errorf("%s has changed what your latest change (%s) changed since: undo takes back only your own changes, never over theirs", who, w.UndoLabel())207+ default:208+ label = w.UndoLabel()209+ w.Undo()210+ }211+ })212+ return label, err213+}214+215+// Ask puts the question on the board and waits for the person.216+func (a *Agent) Ask(ctx context.Context, p *sdk.ElicitParams) (*sdk.ElicitResult, error) {217+ q, err := NewAsk(p.Message, p.RequestedSchema)218+ if err != nil {219+ return nil, err220+ }221+ ans, err := a.wait(ctx, q)222+ if err != nil {223+ return nil, err224+ }225+ return &sdk.ElicitResult{Action: ans.Action, Content: ans.Content}, nil226+}227+228+// grant asks leave for what, unless the person gave it for the session.229+func (a *Agent) grant(ctx context.Context, what, message string) error {230+ ans, err := a.wait(ctx, NewGrant(what, message))231+ if err != nil {232+ return err233+ }234+ if ans.Action != "accept" {235+ return errors.New("the person didn't allow it")236+ }237+ return nil238+}239+240+// wait asks q and waits for the answer, withdrawing the question when241+// ctx ends first.242+func (a *Agent) wait(ctx context.Context, q *Ask) (Answer, error) {243+ q.Author, q.Agent, q.Color = a.seat.ID(), a.seat.Name(), a.seat.Color()244+ a.seat.Do(func(*sheet.Workbook) {245+ BoardOf(a.seat).AddAsk(q)246+ a.seat.Touch()247+ })248+ select {249+ case ans := <-q.Reply():250+ return ans, nil251+ case <-ctx.Done():252+ a.seat.Do(func(*sheet.Workbook) {253+ BoardOf(a.seat).Withdraw(q)254+ a.seat.Touch()255+ })256+ return Answer{}, ctx.Err()257+ }258+}259+260+// Suggestions lists the agent's suggestions.261+func (a *Agent) Suggestions(context.Context) ([]mcp.Suggestion, error) {262+ var out []mcp.Suggestion263+ a.seat.Do(func(*sheet.Workbook) {264+ for _, s := range BoardOf(a.seat).Of(a.seat.ID()) {265+ n := len(s.Cells)266+ acc, rej := s.Count(CellAccepted), s.Count(CellRejected)267+ if s.Whole() {268+ acc, rej = acc*n, rej*n269+ }270+ out = append(out, mcp.Suggestion{ID: s.ID, Label: s.Label, Message: s.Message, State: s.Outcome(), Cells: n, Accepted: acc, Rejected: rej})271+ }272+ })273+ return out, nil274+}275+276+// Scope is the board's scope in words.277+func (a *Agent) Scope() string {278+ var s string279+ a.seat.Do(func(*sheet.Workbook) { s = BoardOf(a.seat).Scope.String() })280+ return s281+}282+283+// RunCell is a request for the keeper's session to run a notebook cell,284+// which it takes from the room (room.Seat.Post).285+type RunCell struct {286+ Notebook string287+ Cell int // from 0288+ Agent string289+}290+291+// RunCell asks leave to run a notebook cell, unless the person gave it292+// for the session, and has the room's keeper run it.293+func (a *Agent) RunCell(ctx context.Context, notebook string, cell int) (string, error) {294+ var err error295+ granted := false296+ a.seat.Do(func(w *sheet.Workbook) {297+ b := BoardOf(a.seat)298+ granted = b.Grants.Notebooks299+ if b.Scope.Kind != Workbook {300+ err = fmt.Errorf("running a notebook cell changes its outputs and the sheets they're sent to, outside what the person let you change (%s)", b.Scope)301+ return302+ }303+ s := w.Lookup(notebook)304+ switch {305+ case s == nil || !s.IsNotebook():306+ err = fmt.Errorf("there's no notebook tab named %s: describe lists them", notebook)307+ case cell < 1 || cell > len(s.NotebookCells()):308+ err = fmt.Errorf("%s has %d cells", notebook, len(s.NotebookCells()))309+ }310+ })311+ if err != nil {312+ return "", err313+ }314+ if !granted {315+ if err := a.grant(ctx, "notebooks", fmt.Sprintf("%s asks to run cell %d of %s with nu, as you", a.seat.Name(), cell, notebook)); err != nil {316+ return "", err317+ }318+ }319+ a.seat.Post(RunCell{Notebook: notebook, Cell: cell - 1, Agent: a.seat.Name()})320+ return fmt.Sprintf("cell %d of %s runs on the person's screen: its output arrives in the workbook as when they run it; read it with describe or read_range", cell, notebook), nil321+}322+323+// report takes the news of the agent's settled suggestions, keeping it324+// for its next write, and returns it.325+func (a *Agent) report() []string {326+ var news []string327+ a.seat.Do(func(*sheet.Workbook) {328+ for _, s := range BoardOf(a.seat).Resolved(a.seat.ID()) {329+ news = append(news, s.Notice())330+ }331+ })332+ if len(news) > 0 {333+ a.mu.Lock()334+ a.notices = append(a.notices, news...)335+ a.mu.Unlock()336+ }337+ return news338+}339+340+// takeNotices is the news kept since the agent last wrote.341+func (a *Agent) takeNotices() []string {342+ a.report()343+ a.mu.Lock()344+ defer a.mu.Unlock()345+ n := a.notices346+ a.notices = nil347+ return n348+}349+350+// Leave takes the agent's questions off the board and gives up its351+// seat. Its suggestions stay for the person to settle.352+func (a *Agent) Leave() {353+ a.seat.Do(func(*sheet.Workbook) {354+ b := BoardOf(a.seat)355+ for _, q := range b.Asks() {356+ if q.Author == a.seat.ID() {357+ b.Withdraw(q)358+ }359+ }360+ a.seat.Touch()361+ })362+ a.seat.Leave()363+}
A internal/cowork/ask.go +284 -0
@@ -0,0 +1,284 @@
1+package cowork2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "slices"8+ "strconv"9+ "strings"10+)11+12+// Questions: the agent asks the person something with the ask tool,13+// shaped as an MCP elicitation request (a message, and a flat object14+// schema of what to answer), and the person answers on the context line15+// of their session, one field at a time. Elicitation goes from a server16+// to its host's user; in live mode the person is on the server's side,17+// so the agent asks through a tool, and the answer comes back as an18+// elicitation's result: accept with the values, decline or cancel.19+// Grants (running notebook cells, JEV) are asked the same way.20+21+// AskKind is what a question is for.22+type AskKind int23+24+const (25+ // Question is the agent's own question (the ask tool).26+ Question AskKind = iota27+ // Grant asks leave to run notebook cells or enter JEV formulas.28+ Grant29+)30+31+// FieldKind is the type of what a field asks for.32+type FieldKind int33+34+const (35+ Text FieldKind = iota36+ Number37+ Integer38+ Boolean39+ Choice40+)41+42+// Field is one thing a question asks for.43+type Field struct {44+ Name, Title, Description string45+ Kind FieldKind46+ Choices []string // a Choice's values47+ Labels []string // and how they're shown, when the schema names them48+ Required bool49+}50+51+// Label is how the field is named to the person.52+func (f Field) Label() string {53+ if f.Title != "" {54+ return f.Title55+ }56+ return f.Name57+}58+59+// Ask is a question waiting for a person's answer.60+type Ask struct {61+ ID int62+ Author int // the agent's seat63+ Agent string64+ Color int65+ Kind AskKind66+ Message string67+ Fields []Field68+ // Grant is what a Grant asks for: "notebooks" or "jev".69+ Grant string70+71+ reply chan Answer72+ taken int // the seat showing it, 0 for none yet73+ done bool74+}75+76+// Answer is the person's answer: the elicitation's action (accept,77+// decline, cancel) and values by field, and for a grant whether it78+// holds for the rest of the session.79+type Answer struct {80+ Action string81+ Content map[string]any82+ Always bool83+}84+85+// NewAsk makes a question, its fields read from an elicitation's86+// requested schema (nil for a yes or no question).87+func NewAsk(message string, schema any) (*Ask, error) {88+ if strings.TrimSpace(message) == "" {89+ return nil, errors.New("ask needs a message: the question shown to the person")90+ }91+ fields, err := Fields(schema)92+ if err != nil {93+ return nil, err94+ }95+ return &Ask{Kind: Question, Message: message, Fields: fields, reply: make(chan Answer, 1)}, nil96+}97+98+// NewGrant asks leave for what ("notebooks" or "jev").99+func NewGrant(what, message string) *Ask {100+ return &Ask{Kind: Grant, Grant: what, Message: message, reply: make(chan Answer, 1)}101+}102+103+// Reply is where the answer arrives, once.104+func (a *Ask) Reply() <-chan Answer { return a.reply }105+106+// Done reports whether the question was answered or withdrawn.107+func (a *Ask) Done() bool { return a.done }108+109+// schemaProp is a property of an elicitation's schema, as far as110+// questions read it.111+type schemaProp struct {112+ Type string `json:"type"`113+ Title string `json:"title"`114+ Description string `json:"description"`115+ Enum []string `json:"enum"`116+ EnumNames []string `json:"enumNames"`117+ OneOf []struct {118+ Const string `json:"const"`119+ Title string `json:"title"`120+ } `json:"oneOf"`121+}122+123+// Fields reads an elicitation's requested schema: an object of124+// top-level string, number, integer and boolean properties, strings with125+// enum (or oneOf consts) being a choice, as MCP allows.126+func Fields(schema any) ([]Field, error) {127+ if schema == nil {128+ return nil, nil129+ }130+ data, err := json.Marshal(schema)131+ if err != nil {132+ return nil, err133+ }134+ var s struct {135+ Type string `json:"type"`136+ Properties map[string]json.RawMessage `json:"properties"`137+ Required []string `json:"required"`138+ }139+ if err := json.Unmarshal(data, &s); err != nil {140+ return nil, fmt.Errorf("requestedSchema: %w", err)141+ }142+ if s.Type != "" && s.Type != "object" {143+ return nil, errors.New("requestedSchema must be an object schema")144+ }145+ names := make([]string, 0, len(s.Properties))146+ for n := range s.Properties {147+ names = append(names, n)148+ }149+ slices.Sort(names) // JSON objects are unordered; required ones first below150+ slices.SortStableFunc(names, func(a, b string) int {151+ return boolRank(slices.Contains(s.Required, b)) - boolRank(slices.Contains(s.Required, a))152+ })153+ var out []Field154+ for _, n := range names {155+ f, err := field(n, s.Properties[n])156+ if err != nil {157+ return nil, err158+ }159+ f.Required = slices.Contains(s.Required, n)160+ out = append(out, f)161+ }162+ return out, nil163+}164+165+func boolRank(b bool) int {166+ if b {167+ return 1168+ }169+ return 0170+}171+172+// field reads one property.173+func field(name string, raw json.RawMessage) (Field, error) {174+ var p schemaProp175+ if err := json.Unmarshal(raw, &p); err != nil {176+ return Field{}, fmt.Errorf("requestedSchema property %s: %w", name, err)177+ }178+ f := Field{Name: name, Title: p.Title, Description: p.Description}179+ switch {180+ case p.Type == "string" && len(p.Enum) > 0:181+ f.Kind, f.Choices, f.Labels = Choice, p.Enum, p.EnumNames182+ case p.Type == "string" && len(p.OneOf) > 0:183+ f.Kind = Choice184+ for _, o := range p.OneOf {185+ f.Choices, f.Labels = append(f.Choices, o.Const), append(f.Labels, o.Title)186+ }187+ case p.Type == "string":188+ f.Kind = Text189+ case p.Type == "number":190+ f.Kind = Number191+ case p.Type == "integer":192+ f.Kind = Integer193+ case p.Type == "boolean":194+ f.Kind = Boolean195+ default:196+ return Field{}, fmt.Errorf("requestedSchema property %s is a %q: questions take strings, numbers, integers, booleans and choices", name, p.Type)197+ }198+ if len(f.Choices) > 9 {199+ return Field{}, fmt.Errorf("requestedSchema property %s has %d choices: at most 9, each a key", name, len(f.Choices))200+ }201+ return f, nil202+}203+204+// Parse reads what the person typed for a Text, Number or Integer205+// field.206+func (f Field) Parse(text string) (any, error) {207+ switch f.Kind {208+ case Number:209+ n, err := strconv.ParseFloat(strings.TrimSpace(text), 64)210+ if err != nil {211+ return nil, fmt.Errorf("%s takes a number", f.Label())212+ }213+ return n, nil214+ case Integer:215+ n, err := strconv.Atoi(strings.TrimSpace(text))216+ if err != nil {217+ return nil, fmt.Errorf("%s takes a whole number", f.Label())218+ }219+ return n, nil220+ }221+ return text, nil222+}223+224+// ChoiceLabel is how choice i is shown.225+func (f Field) ChoiceLabel(i int) string {226+ if i < len(f.Labels) && f.Labels[i] != "" {227+ return f.Labels[i]228+ }229+ return f.Choices[i]230+}231+232+// AddAsk puts a question on the board for a person to answer.233+func (b *Board) AddAsk(a *Ask) {234+ b.nextAsk++235+ a.ID = b.nextAsk236+ b.asks = append(b.asks, a)237+}238+239+// NextAsk is the first question waiting that the person at seat should240+// answer: the host's, or in a room without one, anyone's not already241+// shown to another who is here (present). It is then seat's to show.242+func (b *Board) NextAsk(seat int, present func(id int) bool) *Ask {243+ if b.Host != 0 && b.Host != seat && present(b.Host) {244+ return nil245+ }246+ for _, a := range b.asks {247+ if a.taken == seat || a.taken == 0 || !present(a.taken) {248+ a.taken = seat249+ return a250+ }251+ }252+ return nil253+}254+255+// Answer answers a question and takes it off the board.256+func (b *Board) Answer(a *Ask, ans Answer) {257+ if a.done {258+ return259+ }260+ a.done = true261+ b.asks = slices.DeleteFunc(b.asks, func(x *Ask) bool { return x == a })262+ if a.Kind == Grant && ans.Action == "accept" && ans.Always {263+ switch a.Grant {264+ case "notebooks":265+ b.Grants.Notebooks = true266+ case "jev":267+ b.Grants.JEV = true268+ }269+ }270+ a.reply <- ans271+}272+273+// Withdraw takes a question off the board unanswered: the agent gave274+// up waiting, or left.275+func (b *Board) Withdraw(a *Ask) {276+ if a.done {277+ return278+ }279+ a.done = true280+ b.asks = slices.DeleteFunc(b.asks, func(x *Ask) bool { return x == a })281+}282+283+// Asks are the questions waiting, oldest first.284+func (b *Board) Asks() []*Ask { return slices.Clone(b.asks) }
A internal/cowork/attach.go +125 -0
@@ -0,0 +1,125 @@
1+package cowork2+3+import (4+ "bufio"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io"9+ "net"10+ "path/filepath"11+)12+13+// Attach is 012 mcp --attach: it finds the session target names (see14+// Pick), refusing a socket that isn't the user's alone, and carries the15+// MCP client's messages from in to it and its answers to out until16+// either side ends. The client's first message, its initialize request,17+// names it to the others. When the session can't be reached, the18+// initialize request is answered with the reason, which hosts show, and19+// Attach returns it.20+func Attach(target string, in io.Reader, out io.Writer) error {21+ br := bufio.NewReader(in)22+ first, err := br.ReadBytes('\n')23+ if err != nil && len(first) == 0 {24+ return err25+ }26+ c, err := dial(target, clientName(first))27+ if err != nil {28+ refuse(out, first, err)29+ return err30+ }31+ defer c.conn.Close()32+ if _, err := c.conn.Write(first); err != nil {33+ return err34+ }35+ go func() {36+ io.Copy(c.conn, br)37+ if uc, ok := c.conn.(*net.UnixConn); ok {38+ uc.CloseWrite()39+ }40+ }()41+ _, err = io.Copy(out, c.r)42+ return err43+}44+45+// attached is a connection past the welcome.46+type attached struct {47+ conn net.Conn48+ r *bufio.Reader49+ w welcome50+}51+52+// dial connects to the session target names, as client.53+func dial(target, client string) (attached, error) {54+ dir, err := SocketDir()55+ if err != nil {56+ return attached{}, err57+ }58+ eps, err := Endpoints(dir)59+ if err != nil {60+ return attached{}, err61+ }62+ ep, roomName, err := Pick(eps, target)63+ if err != nil {64+ return attached{}, err65+ }66+ if err := checkOwner(filepath.Dir(ep.Socket)); err != nil {67+ return attached{}, err68+ }69+ if err := checkOwner(ep.Socket); err != nil {70+ return attached{}, err71+ }72+ conn, err := net.Dial("unix", ep.Socket)73+ if err != nil {74+ return attached{}, fmt.Errorf("the session doesn't answer: %w", err)75+ }76+ if err := json.NewEncoder(conn).Encode(hello{Version: 1, Room: roomName, Client: client}); err != nil {77+ conn.Close()78+ return attached{}, err79+ }80+ a := attached{conn: conn, r: bufio.NewReader(conn)}81+ line, err := a.r.ReadBytes('\n')82+ if err == nil {83+ err = json.Unmarshal(line, &a.w)84+ }85+ switch {86+ case err != nil:87+ conn.Close()88+ return attached{}, fmt.Errorf("the session refused: %w", err)89+ case a.w.Error != "":90+ conn.Close()91+ return attached{}, errors.New(a.w.Error)92+ }93+ return a, nil94+}95+96+// clientName is the MCP client's name from its initialize request: its97+// title, else its name.98+func clientName(initialize []byte) string {99+ var req struct {100+ Params struct {101+ ClientInfo struct {102+ Name string `json:"name"`103+ Title string `json:"title"`104+ } `json:"clientInfo"`105+ } `json:"params"`106+ }107+ json.Unmarshal(initialize, &req)108+ if t := req.Params.ClientInfo.Title; t != "" {109+ return t110+ }111+ return req.Params.ClientInfo.Name112+}113+114+// refuse answers the client's first request with err, as a JSON-RPC115+// error its host shows.116+func refuse(out io.Writer, first []byte, err error) {117+ var req struct {118+ ID json.RawMessage `json:"id"`119+ }120+ if json.Unmarshal(first, &req) != nil || len(req.ID) == 0 {121+ return122+ }123+ resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32000, "message": "012: " + err.Error()}})124+ out.Write(append(resp, '\n'))125+}
A internal/cowork/board.go +250 -0
@@ -0,0 +1,250 @@
1+package cowork2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "time"8+9+ "github.com/FelineStateMachine/012/internal/room"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// BoardKey is the room value (room.Seat.Value) holding its Board.14+const BoardKey = "agents"15+16+// BoardOf is the Board of seat's room, made the first time. Call it in17+// a turn.18+func BoardOf(seat *room.Seat) *Board {19+ return seat.Value(BoardKey, func() any { return &Board{} }).(*Board)20+}21+22+// Board is what a room shares of its agents: their suggestions, their23+// questions to the people, and what the people let them do.24+type Board struct {25+ // Scope is what agents may change, as the person who invited them26+ // chose; the whole workbook in 012 serve.27+ Scope Scope28+ // Direct lets agents' changes be made at once, as their own steps,29+ // rather than suggested ("Let agents edit directly").30+ Direct bool31+ Grants Grants32+ // Host is the seat of the person who invited the agents, who is33+ // asked their questions; 0 in 012 serve, where any person is.34+ Host int35+36+ list []*Suggestion // oldest first37+ next int38+ asks []*Ask39+ nextAsk int40+}41+42+// CellState is what became of one cell of a suggestion.43+type CellState int44+45+const (46+ CellPending CellState = iota47+ CellAccepted48+ CellRejected49+)50+51+// Suggestion is one of the agents's change waiting for a person: the proposal,52+// who made it and why, and what became of each of its cells.53+type Suggestion struct {54+ ID int55+ Author int // the agent's seat56+ Agent string // its name57+ Color int58+ Message string59+ At time.Time60+ *sheet.Proposal61+ // States are its cells', in the order of Proposal.Cells; a change62+ // made whole only has one, for all of it.63+ States []CellState64+ // Failed is why accepting it failed, when it did.65+ Failed string66+ reported bool67+}68+69+// Pending reports whether any of it waits for the person.70+func (s *Suggestion) Pending() bool { return slices.Contains(s.States, CellPending) }71+72+// Count is how many of its cells are in state st.73+func (s *Suggestion) Count(st CellState) int {74+ n := 075+ for _, x := range s.States {76+ if x == st {77+ n++78+ }79+ }80+ return n81+}82+83+// Outcome is what became of it in words: pending, accepted, accepted in84+// part or rejected.85+func (s *Suggestion) Outcome() string {86+ switch acc, rej := s.Count(CellAccepted), s.Count(CellRejected); {87+ case s.Pending():88+ return "pending"89+ case rej == 0:90+ return "accepted"91+ case acc == 0:92+ return "rejected"93+ }94+ return "accepted in part"95+}96+97+// Add puts a suggestion on the board and numbers it.98+func (b *Board) Add(s *Suggestion) int {99+ b.next++100+ s.ID = b.next101+ if s.At.IsZero() {102+ s.At = time.Now()103+ }104+ n := len(s.Cells)105+ if s.Whole() || n == 0 {106+ n = 1107+ }108+ s.States = make([]CellState, n)109+ b.list = append(b.list, s)110+ return s.ID111+}112+113+// Pending are the suggestions still waiting, oldest first.114+func (b *Board) Pending() []*Suggestion {115+ var out []*Suggestion116+ for _, s := range b.list {117+ if s.Pending() {118+ out = append(out, s)119+ }120+ }121+ return out122+}123+124+// Get is the suggestion numbered id.125+func (b *Board) Get(id int) *Suggestion {126+ for _, s := range b.list {127+ if s.ID == id {128+ return s129+ }130+ }131+ return nil132+}133+134+// Of are the suggestions agent's seat made, oldest first.135+func (b *Board) Of(author int) []*Suggestion {136+ var out []*Suggestion137+ for _, s := range b.list {138+ if s.Author == author {139+ out = append(out, s)140+ }141+ }142+ return out143+}144+145+// At is the newest pending suggestion setting the cell a of the sheet146+// named name, and the index of the cell in it.147+func (b *Board) At(name string, a sheet.Addr) (*Suggestion, int, bool) {148+ for i := len(b.list) - 1; i >= 0; i-- {149+ s := b.list[i]150+ for j, c := range s.Cells {151+ if c.Sheet == name && c.At == a && s.state(j) == CellPending {152+ return s, j, true153+ }154+ }155+ }156+ return nil, 0, false157+}158+159+// state is cell i's state; all of a whole change's cells are its one.160+func (s *Suggestion) state(i int) CellState {161+ if s.Whole() {162+ return s.States[0]163+ }164+ return s.States[i]165+}166+167+// ErrGone is accepting or rejecting a suggestion no longer waiting.168+var ErrGone = errors.New("that suggestion isn't waiting any more")169+170+// Accept makes the cells of suggestion id that are picked (nil: all it171+// has pending) on w, as one step in the agent's name, and marks them172+// accepted. A change made whole only is accepted whole.173+func (b *Board) Accept(w *sheet.Workbook, id int, cells []int) error {174+ s := b.Get(id)175+ if s == nil || !s.Pending() {176+ return ErrGone177+ }178+ if s.Whole() && cells != nil {179+ return sheet.ErrWhole180+ }181+ pick := b.picked(s, cells)182+ author := w.Author()183+ w.SetAuthor(s.Author)184+ err := s.Apply(w, func(i int) bool { return pick[i] })185+ w.SetAuthor(author)186+ if err != nil {187+ s.Failed = err.Error()188+ return fmt.Errorf("accepting %s's suggestion: %w", s.Agent, err)189+ }190+ b.mark(s, pick, CellAccepted)191+ return nil192+}193+194+// Reject marks the cells of suggestion id picked (nil: all it has195+// pending) rejected, changing nothing.196+func (b *Board) Reject(id int, cells []int) error {197+ s := b.Get(id)198+ if s == nil || !s.Pending() {199+ return ErrGone200+ }201+ b.mark(s, b.picked(s, cells), CellRejected)202+ return nil203+}204+205+// picked are the pending cells of s among cells, all of them for nil.206+func (b *Board) picked(s *Suggestion, cells []int) map[int]bool {207+ pick := map[int]bool{}208+ for i := range s.Cells {209+ if s.state(i) == CellPending && (cells == nil || slices.Contains(cells, i)) {210+ pick[i] = true211+ }212+ }213+ return pick214+}215+216+// mark sets the picked cells' state.217+func (b *Board) mark(s *Suggestion, pick map[int]bool, st CellState) {218+ if s.Whole() || len(s.Cells) == 0 {219+ s.States[0] = st220+ return221+ }222+ for i := range pick {223+ s.States[i] = st224+ }225+}226+227+// Resolved are the agent's suggestions settled since it last asked,228+// which it hasn't been told of.229+func (b *Board) Resolved(author int) []*Suggestion {230+ var out []*Suggestion231+ for _, s := range b.list {232+ if s.Author == author && !s.Pending() && !s.reported {233+ s.reported = true234+ out = append(out, s)235+ }236+ }237+ return out238+}239+240+// Notice is what the agent is told of a settled suggestion.241+func (s *Suggestion) Notice() string {242+ what := fmt.Sprintf("Suggestion %d (%s) was %s", s.ID, s.Label, s.Outcome())243+ if n := len(s.Cells); n > 1 && s.Outcome() == "accepted in part" {244+ what += fmt.Sprintf(": %d of its %d cells", s.Count(CellAccepted), n)245+ }246+ if s.Failed != "" {247+ what += "; accepting it failed: " + s.Failed248+ }249+ return what250+}
A internal/cowork/board_test.go +337 -0
@@ -0,0 +1,337 @@
1+package cowork2+3+import (4+ "context"5+ "errors"6+ "strings"7+ "testing"8+ "time"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+12+ "github.com/FelineStateMachine/012/internal/headless"13+ "github.com/FelineStateMachine/012/internal/mcp"14+ "github.com/FelineStateMachine/012/internal/notebook"15+ "github.com/FelineStateMachine/012/internal/room"16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// person is a participant standing for a session.20+type person struct{ name string }21+22+func (p *person) Name() string { return p.name }23+func (p *person) Notify() {}24+25+// bot is one of the agents participant without a connection.26+type bot struct{ person }27+28+func (*bot) Agent() {}29+30+// liveRoom is a room with ann in it, on a workbook of 1, 2, 3 in A1:A331+// and their sum in A4, and the agent attached.32+func liveRoom(t *testing.T) (ann *room.Seat, agent *Agent) {33+ t.Helper()34+ g := room.NewRegistry(room.Edit)35+ ann, _ = g.Join("k", &person{"ann"}, func() *sheet.Workbook {36+ w := sheet.New().Book()37+ for i, in := range []string{"1", "2", "3", "=SUM(A1:A3)"} {38+ w.Sheet(0).Set(sheet.Addr{Row: i}, in)39+ }40+ w.AddSheet("Q3", 1)41+ return w42+ })43+ ann.Do(func(*sheet.Workbook) { ann.SetSaved(room.Saved{Name: "budget.012"}) })44+ seat, ok := g.JoinOpen("k", &bot{person{"claude"}})45+ if !ok {46+ t.Fatal("the agent couldn't join")47+ }48+ return ann, NewAgent(seat)49+}50+51+func set(entries ...string) func(*sheet.Workbook) error {52+ return func(w *sheet.Workbook) error {53+ var es []headless.Entry54+ for i := 0; i+1 < len(entries); i += 2 {55+ es = append(es, headless.Entry{Ref: entries[i], Input: entries[i+1]})56+ }57+ _, err := headless.Set(w, es, headless.SetOptions{})58+ return err59+ }60+}61+62+func value(s *room.Seat, sheetName, ref string) sheet.Value {63+ var v sheet.Value64+ s.Do(func(w *sheet.Workbook) {65+ a, _ := sheet.ParseAddr(ref)66+ v = w.Lookup(sheetName).Value(a)67+ })68+ return v69+}70+71+func TestSuggestionWaitsForThePerson(t *testing.T) {72+ ann, a := liveRoom(t)73+ made, err := a.Make(context.Background(), mcp.Making{Label: "set", Message: "fix the totals", Fn: set("A1", "10", "A2", "20")})74+ if err != nil {75+ t.Fatal(err)76+ }77+ if made.Applied || made.Suggestion != 1 || len(made.Changes) == 0 {78+ t.Fatalf("made %+v", made)79+ }80+ if v := value(ann, "Sheet1", "A4"); v.Num != 6 {81+ t.Fatalf("the suggestion was made: A4 is %v", v)82+ }83+ ann.Do(func(*sheet.Workbook) {84+ b := BoardOf(ann)85+ s, i, ok := b.At("Sheet1", sheet.Addr{Row: 1})86+ if !ok || s.ID != 1 || i != 1 || s.Message != "fix the totals" || s.Agent != "claude" {87+ t.Errorf("A2's suggestion: %+v %d %v", s, i, ok)88+ }89+ })90+}91+92+func TestSuggestionAcceptedIsTheAgentsStep(t *testing.T) {93+ ann, a := liveRoom(t)94+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10", "A2", "20")})95+ ann.Do(func(w *sheet.Workbook) {96+ if err := BoardOf(ann).Accept(w, 1, nil); err != nil {97+ t.Fatal(err)98+ }99+ if w.CanUndo() {100+ t.Error("ann can undo the agent's step")101+ }102+ })103+ if v := value(ann, "Sheet1", "A4"); v.Num != 33 {104+ t.Fatalf("A4 is %v after accepting", v)105+ }106+ news := a.report()107+ if len(news) != 1 || !strings.Contains(news[0], "was accepted") {108+ t.Fatalf("news %v", news)109+ }110+ if label, err := a.Undo(context.Background()); err != nil || label != "set" {111+ t.Fatalf("the agent's undo: %q %v", label, err)112+ }113+ if v := value(ann, "Sheet1", "A4"); v.Num != 6 {114+ t.Fatalf("A4 is %v after the agent's undo", v)115+ }116+}117+118+func TestSuggestionByCell(t *testing.T) {119+ ann, a := liveRoom(t)120+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10", "A2", "20", "A3", "30")})121+ ann.Do(func(w *sheet.Workbook) {122+ b := BoardOf(ann)123+ if err := b.Accept(w, 1, []int{0}); err != nil {124+ t.Fatal(err)125+ }126+ if err := b.Reject(1, []int{1}); err != nil {127+ t.Fatal(err)128+ }129+ if !b.Get(1).Pending() {130+ t.Error("A3 isn't pending")131+ }132+ b.Accept(w, 1, nil) // the rest: A3133+ })134+ if v := value(ann, "Sheet1", "A4"); v.Num != 42 {135+ t.Fatalf("A4 is %v: 10 + 2 + 30", v)136+ }137+ list, _ := a.Suggestions(context.Background())138+ if len(list) != 1 || list[0].State != "accepted in part" || list[0].Accepted != 2 || list[0].Rejected != 1 {139+ t.Fatalf("suggestions %+v", list)140+ }141+}142+143+func TestSuggestionRejected(t *testing.T) {144+ ann, a := liveRoom(t)145+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10")})146+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Reject(1, nil) })147+ made, _ := a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("B1", "x")})148+ if len(made.Notices) != 1 || !strings.Contains(made.Notices[0], "Suggestion 1 (set) was rejected") {149+ t.Fatalf("notices %v", made.Notices)150+ }151+ if v := value(ann, "Sheet1", "A1"); v.Num != 1 {152+ t.Fatal("a rejected suggestion changed A1")153+ }154+ ann.Do(func(w *sheet.Workbook) {155+ if err := BoardOf(ann).Accept(w, 1, nil); !errors.Is(err, ErrGone) {156+ t.Errorf("accepting a rejected suggestion: %v", err)157+ }158+ })159+}160+161+func TestDirectEdits(t *testing.T) {162+ ann, a := liveRoom(t)163+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Direct = true })164+ made, err := a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10")})165+ if err != nil || !made.Applied || made.Suggestion != 0 {166+ t.Fatalf("made %+v, %v", made, err)167+ }168+ if v := value(ann, "Sheet1", "A4"); v.Num != 15 {169+ t.Fatalf("A4 is %v", v)170+ }171+ var where room.Presence172+ ann.Do(func(*sheet.Workbook) { where = ann.Peers()[0].Presence })173+ if where.Cursor != (sheet.Addr{}) || where.Sheet == nil {174+ t.Errorf("the agent's pointer isn't on its change: %+v", where)175+ }176+}177+178+func TestScopeRefuses(t *testing.T) {179+ for _, tc := range []struct {180+ name string181+ scope func(w *sheet.Workbook) Scope182+ fn func(*sheet.Workbook) error183+ want string184+ }{185+ {"read only", func(*sheet.Workbook) Scope { return Scope{Kind: ReadOnly} }, set("A1", "5"), "read only"},186+ {"another sheet", func(w *sheet.Workbook) Scope { return Scope{Kind: OneSheet, Sheet: w.Lookup("Q3")} }, set("A1", "5"), "Sheet1!A1, outside what the person let you change (sheet Q3)"},187+ {"outside the range", func(w *sheet.Workbook) Scope {188+ return Scope{Kind: OneRange, Sheet: w.Sheet(0), Range: sheet.Rect{To: sheet.Addr{Row: 2}}}189+ }, set("B1", "5"), "Sheet1!B1, outside what the person let you change (Sheet1!A1:A3)"},190+ {"rows in a range", func(w *sheet.Workbook) Scope {191+ return Scope{Kind: OneRange, Sheet: w.Sheet(0), Range: sheet.Rect{To: sheet.Addr{Row: 2}}}192+ }, func(w *sheet.Workbook) error { return w.Sheet(0).InsertRows(0, 1) }, "inserts or deletes rows"},193+ {"a sheet added", func(w *sheet.Workbook) Scope { return Scope{Kind: OneSheet, Sheet: w.Sheet(0)} },194+ func(w *sheet.Workbook) error { _, err := w.AddSheet("New", 2); return err }, "adds, deletes, renames or moves sheets"},195+ } {196+ t.Run(tc.name, func(t *testing.T) {197+ ann, a := liveRoom(t)198+ ann.Do(func(w *sheet.Workbook) { BoardOf(ann).Scope = tc.scope(w) })199+ _, err := a.Make(context.Background(), mcp.Making{Label: "x", Fn: tc.fn})200+ if err == nil || !strings.Contains(err.Error(), tc.want) {201+ t.Fatalf("got %v, want %q", err, tc.want)202+ }203+ })204+ }205+ ann, a := liveRoom(t)206+ ann.Do(func(w *sheet.Workbook) { BoardOf(ann).Scope = Scope{Kind: OneSheet, Sheet: w.Sheet(0)} })207+ if _, err := a.Make(context.Background(), mcp.Making{Label: "x", Fn: set("C3", "5")}); err != nil {208+ t.Fatalf("inside the sheet: %v", err)209+ }210+ if _, err := a.Focus(context.Background(), "Q3!A1"); err == nil {211+ t.Fatal("focus outside the scope")212+ }213+ if at, err := a.Focus(context.Background(), "B2:C3"); err != nil || at != "Sheet1!B2:C3" {214+ t.Fatalf("focus: %q %v", at, err)215+ }216+}217+218+// answer answers the first question on the board as a session would.219+func answer(t *testing.T, ann *room.Seat, ans Answer) *Ask {220+ t.Helper()221+ deadline := time.Now().Add(5 * time.Second)222+ for time.Now().Before(deadline) {223+ var q *Ask224+ ann.Do(func(*sheet.Workbook) {225+ b := BoardOf(ann)226+ if q = b.NextAsk(ann.ID(), func(int) bool { return true }); q != nil {227+ b.Answer(q, ans)228+ }229+ })230+ if q != nil {231+ return q232+ }233+ time.Sleep(5 * time.Millisecond)234+ }235+ t.Fatal("no question came")236+ return nil237+}238+239+func TestAskReturnsTheAnswer(t *testing.T) {240+ ann, a := liveRoom(t)241+ got := make(chan *sdk.ElicitResult, 1)242+ go func() {243+ res, err := a.Ask(context.Background(), &sdk.ElicitParams{Message: "Overwrite B2:B40?",244+ RequestedSchema: map[string]any{"type": "object", "properties": map[string]any{245+ "sure": map[string]any{"type": "boolean", "title": "Sure"},246+ "which": map[string]any{"type": "string", "enum": []string{"a", "b"}}}, "required": []string{"which"}}})247+ if err != nil {248+ t.Error(err)249+ }250+ got <- res251+ }()252+ q := answer(t, ann, Answer{Action: "accept", Content: map[string]any{"sure": true, "which": "b"}})253+ if q.Message != "Overwrite B2:B40?" || len(q.Fields) != 2 || q.Fields[0].Name != "which" || q.Fields[0].Kind != Choice || q.Fields[1].Kind != Boolean {254+ t.Fatalf("question %+v", q)255+ }256+ res := <-got257+ if res.Action != "accept" || res.Content["which"] != "b" {258+ t.Fatalf("result %+v", res)259+ }260+}261+262+func TestAskWithdrawnWhenTheAgentGivesUp(t *testing.T) {263+ ann, a := liveRoom(t)264+ ctx, cancel := context.WithCancel(context.Background())265+ done := make(chan error, 1)266+ go func() {267+ _, err := a.Ask(ctx, &sdk.ElicitParams{Message: "Sure?"})268+ done <- err269+ }()270+ for n := 0; n == 0; {271+ ann.Do(func(*sheet.Workbook) { n = len(BoardOf(ann).Asks()) })272+ time.Sleep(time.Millisecond)273+ }274+ cancel()275+ if err := <-done; !errors.Is(err, context.Canceled) {276+ t.Fatal(err)277+ }278+ ann.Do(func(*sheet.Workbook) {279+ if n := len(BoardOf(ann).Asks()); n != 0 {280+ t.Errorf("%d questions left", n)281+ }282+ })283+}284+285+func TestAskRefusesSchemasItCantShow(t *testing.T) {286+ _, a := liveRoom(t)287+ _, err := a.Ask(context.Background(), &sdk.ElicitParams{Message: "Where?",288+ RequestedSchema: map[string]any{"type": "object", "properties": map[string]any{"at": map[string]any{"type": "array"}}}})289+ if err == nil || !strings.Contains(err.Error(), "questions take strings") {290+ t.Fatal(err)291+ }292+}293+294+func TestJEVDirectAsksFirst(t *testing.T) {295+ ann, a := liveRoom(t)296+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Direct = true })297+ done := make(chan error, 1)298+ go func() {299+ _, err := a.Make(context.Background(), mcp.Making{Label: "ask", Fn: set("B1", `=JEV.TEST(A1, "odd?")`)})300+ done <- err301+ }()302+ q := answer(t, ann, Answer{Action: "accept", Always: true})303+ if q.Kind != Grant || q.Grant != "jev" {304+ t.Fatalf("question %+v", q)305+ }306+ if err := <-done; err != nil {307+ t.Fatal(err)308+ }309+ ann.Do(func(*sheet.Workbook) {310+ if !BoardOf(ann).Grants.JEV {311+ t.Error("always didn't grant JEV for the session")312+ }313+ })314+}315+316+func TestNotebookRunsAskAndGoToTheKeeper(t *testing.T) {317+ ann, a := liveRoom(t)318+ ann.Do(func(w *sheet.Workbook) { w.AddNotebook("Notes", w.Sheet(0)) })319+ ann.Do(func(w *sheet.Workbook) {320+ w.Lookup("Notes").SetNotebookCells("add", []notebook.Cell{{Source: "ls"}})321+ })322+ if _, err := a.RunCell(context.Background(), "Nope", 1); err == nil {323+ t.Fatal("ran a cell of no notebook")324+ }325+ done := make(chan error, 1)326+ go func() {327+ _, err := a.RunCell(context.Background(), "Notes", 1)328+ done <- err329+ }()330+ q := answer(t, ann, Answer{Action: "decline"})331+ if q.Grant != "notebooks" {332+ t.Fatalf("question %+v", q)333+ }334+ if err := <-done; err == nil {335+ t.Fatal("a declined run went ahead")336+ }337+}
A internal/cowork/cowork.go +109 -0
@@ -0,0 +1,109 @@
1+// Package cowork is live mode (docs/agents/live.md): the agent working in2+// a running 012 session, as one of the participants of the session's3+// room (package room). A session listens on a socket only its user can4+// reach (Listen); 012 mcp --attach connects to it (Attach) and carries5+// the agent's MCP messages to a server running in the session, whose6+// tools stand on the room's workbook (Agent, an mcp.Live). What the7+// agent reads is the workbook on the screen; what it changes is a8+// proposal (sheet.Propose) on the room's Board, marked on the person's9+// screen until they accept or reject it, or, when they let agents edit10+// directly, a step of the agent's own. The Board also carries the11+// agent's questions to the person (Ask) and what the person allows it.12+//13+// Everything on the Board is the room's, so it is read and changed in14+// a turn (room.Seat.Do), as the workbook is.15+package cowork16+17+import (18+ "errors"19+ "fmt"20+ "slices"21+22+ "github.com/FelineStateMachine/012/internal/sheet"23+)24+25+// ScopeKind is how much of the workbook the agent may change.26+type ScopeKind int27+28+const (29+ // Workbook lets the agent change anything.30+ Workbook ScopeKind = iota31+ // OneSheet lets it change one sheet's cells, lines and charts.32+ OneSheet33+ // OneRange lets it change the cells of one range.34+ OneRange35+ // ReadOnly lets it change nothing.36+ ReadOnly37+)38+39+// Scope is what the person invited the agent to change. The agent reads40+// the whole workbook whatever the scope, as formulas in it read cells41+// outside it; the scope bounds its changes and where it points.42+type Scope struct {43+ Kind ScopeKind44+ Sheet *sheet.Sheet // OneSheet, OneRange45+ Range sheet.Rect // OneRange46+}47+48+// String is the scope in words: "the whole workbook", "sheet Q3",49+// "Q3!A1:C9", "read only".50+func (s Scope) String() string {51+ switch s.Kind {52+ case OneSheet:53+ return "sheet " + s.Sheet.Name()54+ case OneRange:55+ return sheet.Qualified(s.Sheet.Name(), s.Range)56+ case ReadOnly:57+ return "read only"58+ }59+ return "the whole workbook"60+}61+62+// ErrReadOnly refuses any change of the agent invited read only.63+var ErrReadOnly = errors.New("you were invited read only: you can read the workbook, point at cells and ask the person, but not change anything; ask them to invite you again with a scope that lets you")64+65+// Allows reports whether the agent may point at r of sheet s.66+func (s Scope) Allows(sh *sheet.Sheet, r sheet.Rect) bool {67+ switch s.Kind {68+ case OneSheet:69+ return sh == s.Sheet70+ case OneRange:71+ return sh == s.Sheet && s.Range.Contains(r.From) && s.Range.Contains(r.To)72+ }73+ return true74+}75+76+// Check refuses a proposal that changes anything outside the scope,77+// saying what.78+func (s Scope) Check(p *sheet.Proposal) error {79+ switch s.Kind {80+ case ReadOnly:81+ return ErrReadOnly82+ case Workbook:83+ return nil84+ }85+ name := s.Sheet.Name()86+ if p.Book || s.Kind == OneRange && len(p.Other) > 0 || slices.ContainsFunc(p.Sheets, func(n string) bool { return n != name }) {87+ return s.outside(p.Other[0])88+ }89+ for _, c := range p.Cells {90+ if c.Sheet != name || s.Kind == OneRange && !s.Range.Contains(c.At) {91+ return s.outside("changes " + sheet.Qualified(c.Sheet, sheet.Rect{From: c.At, To: c.At}))92+ }93+ }94+ return nil95+}96+97+func (s Scope) outside(what string) error {98+ return fmt.Errorf("the change %s, outside what the person let you change (%s): keep to it, or ask them to invite you with a wider scope", what, s)99+}100+101+// Grants are what the person lets agents do beyond changing cells, for102+// the session: run notebook cells with nu, and enter formulas that ask103+// a hosted model over the network (JEV) directly, as untrusted macros104+// ask before either. Without a grant, the agent's request asks the105+// person first.106+type Grants struct {107+ Notebooks bool108+ JEV bool109+}
A internal/cowork/endpoint.go +158 -0
@@ -0,0 +1,158 @@
1+package cowork2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "io/fs"8+ "net"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/012/internal/config"16+)17+18+// Where sessions listen: a folder of the user's own (0700), the runtime19+// folder's 012 when the system has one ($XDG_RUNTIME_DIR), else agents20+// in 012's config folder. Each listening session has a Unix socket21+// there (0600), <pid>-<n>.sock, and beside it an endpoint file,22+// <pid>-<n>.json, saying what it is, which 012 mcp --attach reads to23+// find it. Windows has Unix sockets too, and its folder is the user's.24+25+// Endpoint is what a listening session says of itself.26+type Endpoint struct {27+ PID int `json:"pid"`28+ Socket string `json:"socket"`29+ // Kind is "session" for a 012 on a terminal, "serve" for 012 serve,30+ // whose agents name the file they join.31+ Kind string `json:"kind"`32+ // Workbook is the session's workbook, as its title names it, or the33+ // folder 012 serve serves.34+ Workbook string `json:"workbook"`35+ Started time.Time `json:"started"`36+}37+38+// SocketDir is the folder sessions listen in.39+func SocketDir() (string, error) {40+ if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" && filepath.IsAbs(d) {41+ return filepath.Join(d, "012"), nil42+ }43+ d, err := config.Dir()44+ if err != nil {45+ return "", err46+ }47+ return filepath.Join(d, "agents"), nil48+}49+50+// ownDir makes dir the user's own, refusing one others can reach.51+func ownDir(dir string) error {52+ if err := os.MkdirAll(dir, 0o700); err != nil {53+ return err54+ }55+ if err := os.Chmod(dir, 0o700); err != nil {56+ return err57+ }58+ return checkOwner(dir)59+}60+61+// Endpoints are the sessions listening in dir, newest first. Files62+// left by sessions that are gone are removed.63+func Endpoints(dir string) ([]Endpoint, error) {64+ entries, err := os.ReadDir(dir)65+ if errors.Is(err, fs.ErrNotExist) {66+ return nil, nil67+ }68+ if err != nil {69+ return nil, err70+ }71+ var out []Endpoint72+ for _, e := range entries {73+ if !strings.HasSuffix(e.Name(), ".json") {74+ continue75+ }76+ path := filepath.Join(dir, e.Name())77+ data, err := os.ReadFile(path)78+ if err != nil {79+ continue80+ }81+ var ep Endpoint82+ if json.Unmarshal(data, &ep) != nil || !alive(ep.Socket) {83+ os.Remove(path)84+ os.Remove(strings.TrimSuffix(path, ".json") + ".sock")85+ continue86+ }87+ out = append(out, ep)88+ }89+ slices.SortFunc(out, func(a, b Endpoint) int { return b.Started.Compare(a.Started) })90+ return out, nil91+}92+93+// alive reports whether a session answers on socket.94+func alive(socket string) bool {95+ c, err := net.DialTimeout("unix", socket, time.Second)96+ if err != nil {97+ return false98+ }99+ c.Close()100+ return true101+}102+103+// Pick is the endpoint target names among eps, and the file the agent104+// of 012 serve joins: "" when only one session listens, a workbook's105+// name, a pid, a socket's path, or with one 012 serve listening, the106+// file to join.107+func Pick(eps []Endpoint, target string) (Endpoint, string, error) {108+ if strings.HasSuffix(target, ".sock") {109+ return Endpoint{Socket: target, Kind: "session"}, "", nil110+ }111+ var match []Endpoint112+ for _, ep := range eps {113+ if target == "" || ep.Kind == "session" && (strings.EqualFold(ep.Workbook, target) || fmt.Sprint(ep.PID) == target ||114+ strings.EqualFold(ep.Workbook, strings.TrimSuffix(filepath.Base(target), filepath.Ext(target)))) {115+ match = append(match, ep)116+ }117+ }118+ if len(match) == 0 && target != "" {119+ for _, ep := range eps {120+ if ep.Kind == "serve" {121+ match = append(match, ep)122+ }123+ }124+ if len(match) == 1 {125+ return match[0], target, nil126+ }127+ }128+ switch len(match) {129+ case 1:130+ if match[0].Kind == "serve" && target == "" {131+ return Endpoint{}, "", fmt.Errorf("012 serve listens for agents on %s: name the file to join, 012 mcp --attach budget.012", match[0].Workbook)132+ }133+ return match[0], "", nil134+ case 0:135+ if target == "" {136+ return Endpoint{}, "", errors.New("no 012 session is listening for agents: start one with 012 --listen, or File > Invite an agent")137+ }138+ return Endpoint{}, "", fmt.Errorf("no 012 session listening for agents has %s open%s", target, listing(eps))139+ }140+ return Endpoint{}, "", fmt.Errorf("several 012 sessions are listening: name one, 012 mcp --attach <name>%s", listing(match))141+}142+143+// listing lists endpoints for an error.144+func listing(eps []Endpoint) string {145+ if len(eps) == 0 {146+ return ""147+ }148+ var b strings.Builder149+ b.WriteString(":")150+ for _, ep := range eps {151+ what := ep.Workbook152+ if ep.Kind == "serve" {153+ what = "012 serve of " + ep.Workbook154+ }155+ fmt.Fprintf(&b, "\n %s (pid %d)", what, ep.PID)156+ }157+ return b.String()158+}
A internal/cowork/listen.go +276 -0
@@ -0,0 +1,276 @@
1+package cowork2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "errors"8+ "fmt"9+ "io"10+ "net"11+ "os"12+ "path/filepath"13+ "strings"14+ "sync"15+ "sync/atomic"16+ "time"17+18+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"19+20+ "github.com/FelineStateMachine/012/internal/mcp"21+ "github.com/FelineStateMachine/012/internal/room"22+ "github.com/FelineStateMachine/012/internal/sheet"23+)24+25+// An attach starts with one line each way before the MCP messages: the26+// agent's hello (which room, and the MCP client's name, read from its27+// initialize request), and the session's welcome (the workbook and the28+// scope, or why not). After that the connection carries MCP's29+// newline-delimited JSON-RPC, as standard input and output would.30+31+// hello is what 012 mcp --attach sends first.32+type hello struct {33+ Version int `json:"version"`34+ Room string `json:"room,omitempty"`35+ Client string `json:"client,omitempty"`36+}37+38+// welcome is the session's answer.39+type welcome struct {40+ Error string `json:"error,omitempty"`41+ Workbook string `json:"workbook,omitempty"`42+ Scope string `json:"scope,omitempty"`43+}44+45+// Options say what a listener serves.46+type Options struct {47+ Registry *room.Registry48+ // Room is the key of the room the agent joins, given the name its49+ // attach gave ("" for none): a session's own, whatever the name, or50+ // in 012 serve the file named, which someone must have open.51+ Room func(name string) (string, error)52+ // Kind and Workbook are what the endpoint file says (Endpoint).53+ Kind, Workbook string54+ // Version is 012's, which the MCP server reports.55+ Version string56+ // Dir is the folder to listen in; "" is SocketDir.57+ Dir string58+}59+60+// Listener is a session listening for agents.61+type Listener struct {62+ opts Options63+ ln net.Listener64+ socket string65+ file string66+67+ mu sync.Mutex68+ conns map[net.Conn]bool69+ closed bool70+ wg sync.WaitGroup71+}72+73+// listeners numbers the sockets of one process.74+var listeners atomic.Int6475+76+// Listen starts listening for agents, on a socket only the user can77+// reach.78+func Listen(o Options) (*Listener, error) {79+ dir := o.Dir80+ if dir == "" {81+ var err error82+ if dir, err = SocketDir(); err != nil {83+ return nil, err84+ }85+ }86+ if err := ownDir(dir); err != nil {87+ return nil, err88+ }89+ base := filepath.Join(dir, fmt.Sprintf("%d-%d", os.Getpid(), listeners.Add(1)))90+ l := &Listener{opts: o, socket: base + ".sock", file: base + ".json", conns: map[net.Conn]bool{}}91+ os.Remove(l.socket)92+ ln, err := net.Listen("unix", l.socket)93+ if err != nil {94+ return nil, err95+ }96+ l.ln = ln97+ if err := os.Chmod(l.socket, 0o600); err != nil {98+ ln.Close()99+ return nil, err100+ }101+ data, _ := json.Marshal(Endpoint{PID: os.Getpid(), Socket: l.socket, Kind: o.Kind, Workbook: o.Workbook, Started: time.Now()})102+ if err := os.WriteFile(l.file, data, 0o600); err != nil {103+ ln.Close()104+ return nil, err105+ }106+ l.wg.Add(1)107+ go l.accept()108+ return l, nil109+}110+111+// Socket is the socket's path.112+func (l *Listener) Socket() string { return l.socket }113+114+// Close stops listening and ends every agent's connection; they leave115+// their rooms once their servers stop, which Wait waits for. Close may116+// be called in a turn: it takes no room's lock.117+func (l *Listener) Close() error {118+ l.mu.Lock()119+ if l.closed {120+ l.mu.Unlock()121+ return nil122+ }123+ l.closed = true124+ for c := range l.conns {125+ c.Close()126+ }127+ l.mu.Unlock()128+ err := l.ln.Close()129+ os.Remove(l.file)130+ os.Remove(l.socket)131+ return err132+}133+134+// Wait waits for the connections to end after Close. It takes rooms'135+// locks, so not in a turn.136+func (l *Listener) Wait() { l.wg.Wait() }137+138+func (l *Listener) accept() {139+ defer l.wg.Done()140+ for {141+ c, err := l.ln.Accept()142+ if err != nil {143+ return144+ }145+ l.mu.Lock()146+ if l.closed {147+ l.mu.Unlock()148+ c.Close()149+ return150+ }151+ l.conns[c] = true152+ l.wg.Add(1)153+ l.mu.Unlock()154+ go func() {155+ defer l.wg.Done()156+ l.serve(c)157+ l.mu.Lock()158+ delete(l.conns, c)159+ l.mu.Unlock()160+ }()161+ }162+}163+164+// serve is one agent's connection: the hello, a seat in the room, and165+// the MCP server on it until the agent goes.166+func (l *Listener) serve(c net.Conn) {167+ defer c.Close()168+ br := bufio.NewReader(c)169+ seat, err := l.greet(c, br)170+ if err != nil {171+ if !errors.Is(err, io.EOF) {172+ json.NewEncoder(c).Encode(welcome{Error: err.Error()})173+ }174+ return175+ }176+ a := NewAgent(seat.seat)177+ defer a.Leave()178+ if err := json.NewEncoder(c).Encode(welcome{Workbook: a.Name(), Scope: a.Scope()}); err != nil {179+ return180+ }181+ ctx, cancel := context.WithCancel(context.Background())182+ defer cancel()183+ srv := mcp.New(mcp.Options{Version: l.opts.Version, Live: a})184+ ss, err := srv.Connect(ctx, &sdk.IOTransport{Reader: io.NopCloser(br), Writer: c}, nil)185+ if err != nil {186+ return187+ }188+ go a.watch(ctx, seat.kick, srv, ss)189+ ss.Wait()190+}191+192+// greeted is the seat the agent was given, and what tells it the room193+// changed.194+type greeted struct {195+ seat *room.Seat196+ kick chan struct{}197+}198+199+// greet reads the agent's hello and seats it in its room.200+func (l *Listener) greet(c net.Conn, br *bufio.Reader) (greeted, error) {201+ if err := checkPeer(c); err != nil {202+ return greeted{}, err203+ }204+ c.SetReadDeadline(time.Now().Add(10 * time.Second))205+ line, err := br.ReadBytes('\n')206+ if err != nil {207+ return greeted{}, io.EOF // a probe (Endpoints) or a client gone208+ }209+ c.SetReadDeadline(time.Time{})210+ var h hello211+ if err := json.Unmarshal(line, &h); err != nil || h.Version != 1 {212+ return greeted{}, errors.New("this isn't 012 mcp --attach of the same version: update the one the agent runs")213+ }214+ key, err := l.opts.Room(h.Room)215+ if err != nil {216+ return greeted{}, err217+ }218+ link := &agentLink{name: agentName(h.Client), kick: make(chan struct{}, 1)}219+ seat, ok := l.opts.Registry.JoinOpen(key, link)220+ if !ok {221+ return greeted{}, errors.New("nobody has that workbook open any more")222+ }223+ return greeted{seat: seat, kick: link.kick}, nil224+}225+226+// agentName is how the others see the agent: its MCP client's name.227+func agentName(client string) string {228+ client = strings.TrimSpace(client)229+ if client == "" {230+ return "agent"231+ }232+ if r := []rune(client); len(r) > 20 {233+ client = string(r[:20])234+ }235+ return client236+}237+238+// agentLink is the agent as a room's participant: telling it the room239+// changed kicks its watch.240+type agentLink struct {241+ name string242+ kick chan struct{}243+}244+245+func (p *agentLink) Name() string { return p.name }246+func (p *agentLink) Agent() {}247+248+func (p *agentLink) Notify() {249+ select {250+ case p.kick <- struct{}{}:251+ default:252+ }253+}254+255+// watch is the agent's Notify: when the person settles one of its256+// suggestions, the news waits for its next write and clients subscribed257+// to the suggestions resource are told; when the room closes, the agent258+// is let go. It runs until ctx ends.259+func (a *Agent) watch(ctx context.Context, kick <-chan struct{}, srv *mcp.Server, ss *sdk.ServerSession) {260+ for {261+ select {262+ case <-ctx.Done():263+ return264+ case <-kick:265+ }266+ if len(a.report()) > 0 {267+ srv.SuggestionsChanged(ctx)268+ }269+ closed := false270+ a.seat.Do(func(*sheet.Workbook) { closed = a.seat.Closed() })271+ if closed {272+ ss.Close()273+ return274+ }275+ }276+}
A internal/cowork/listen_test.go +220 -0
@@ -0,0 +1,220 @@
1+package cowork2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "io"8+ "net"9+ "os"10+ "path/filepath"11+ "strings"12+ "testing"13+ "time"14+15+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"16+17+ "github.com/FelineStateMachine/012/internal/room"18+ "github.com/FelineStateMachine/012/internal/sheet"19+)20+21+// socketDir is a short folder for sockets: their paths are limited to22+// about 100 bytes.23+func socketDir(t *testing.T) string {24+ t.Helper()25+ d, err := os.MkdirTemp("", "o12")26+ if err != nil {27+ t.Fatal(err)28+ }29+ t.Cleanup(func() { os.RemoveAll(d) })30+ return filepath.Join(d, "run")31+}32+33+// listening is a session of ann's on budget.012 listening for agents.34+func listening(t *testing.T) (*room.Seat, *Listener) {35+ t.Helper()36+ g := room.NewRegistry(room.Edit)37+ ann, _ := g.Join("/home/ann/budget.012", &person{"ann"}, func() *sheet.Workbook {38+ w := sheet.New().Book()39+ w.Sheet(0).Set(sheet.Addr{}, "1")40+ w.Sheet(0).Set(sheet.Addr{Row: 1}, "=A1*2")41+ return w42+ })43+ ann.Do(func(*sheet.Workbook) { ann.SetSaved(room.Saved{Name: "budget.012"}) })44+ l, err := Listen(Options{Registry: g, Room: func(string) (string, error) { return "/home/ann/budget.012", nil },45+ Kind: "session", Workbook: "budget", Dir: socketDir(t)})46+ if err != nil {47+ t.Fatal(err)48+ }49+ t.Cleanup(func() { l.Close(); l.Wait() })50+ return ann, l51+}52+53+// attachClient connects an MCP client to the listener as 012 mcp54+// --attach does.55+func attachClient(t *testing.T, l *Listener, name string, opts ...*sdk.ClientOptions) *sdk.ClientSession {56+ t.Helper()57+ conn, err := net.Dial("unix", l.Socket())58+ if err != nil {59+ t.Fatal(err)60+ }61+ json.NewEncoder(conn).Encode(hello{Version: 1, Client: name})62+ r := bufio.NewReader(conn)63+ line, _ := r.ReadBytes('\n')64+ var w welcome65+ if err := json.Unmarshal(line, &w); err != nil || w.Error != "" || w.Workbook != "budget" || w.Scope != "the whole workbook" {66+ t.Fatalf("welcome %s: %v", line, err)67+ }68+ var o *sdk.ClientOptions69+ if len(opts) > 0 {70+ o = opts[0]71+ }72+ c := sdk.NewClient(&sdk.Implementation{Name: name, Version: "1"}, o)73+ cs, err := c.Connect(context.Background(), &sdk.IOTransport{Reader: io.NopCloser(r), Writer: conn}, nil)74+ if err != nil {75+ t.Fatal(err)76+ }77+ t.Cleanup(func() { cs.Close() })78+ return cs79+}80+81+func callTool(t *testing.T, cs *sdk.ClientSession, name string, args any) map[string]any {82+ t.Helper()83+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args})84+ if err != nil {85+ t.Fatalf("%s: %v", name, err)86+ }87+ if res.IsError {88+ t.Fatalf("%s: %s", name, res.Content[0].(*sdk.TextContent).Text)89+ }90+ var out map[string]any91+ data, _ := json.Marshal(res.StructuredContent)92+ json.Unmarshal(data, &out)93+ return out94+}95+96+// The agent attached over the socket sits in the room, reads the97+// workbook as it is, suggests, and hears what became of it.98+func TestAttachedAgentWorksInTheRoom(t *testing.T) {99+ ann, l := listening(t)100+ info, err := os.Stat(l.Socket())101+ if err != nil || info.Mode().Perm() != 0o600 {102+ t.Fatalf("the socket's permissions: %v %v", info.Mode(), err)103+ }104+ updated := make(chan string, 4)105+ cs := attachClient(t, l, "claude", &sdk.ClientOptions{ResourceUpdatedHandler: func(_ context.Context, r *sdk.ResourceUpdatedNotificationRequest) {106+ updated <- r.Params.URI107+ }})108+ if err := cs.Subscribe(context.Background(), &sdk.SubscribeParams{URI: "o12://live/suggestions"}); err != nil {109+ t.Fatal(err)110+ }111+ var peers []room.Peer112+ ann.Do(func(*sheet.Workbook) { peers = ann.Peers() })113+ if len(peers) != 1 || peers[0].Name != "claude" || !peers[0].Agent {114+ t.Fatalf("peers %+v", peers)115+ }116+ read := callTool(t, cs, "read_range", map[string]any{"ref": "A1:A2"})117+ if rows := read["values"].([]any); rows[1].([]any)[0] != 2.0 {118+ t.Fatalf("read %v", read)119+ }120+ ann.Do(func(w *sheet.Workbook) { w.Sheet(0).Set(sheet.Addr{}, "5") })121+ read = callTool(t, cs, "read_range", map[string]any{"ref": "A1:A2"})122+ if rows := read["values"].([]any); rows[1].([]any)[0] != 10.0 {123+ t.Fatalf("the agent doesn't see ann's edit: %v", read)124+ }125+ wrote := callTool(t, cs, "write_cells", map[string]any{"entries": []map[string]any{{"ref": "B1", "input": "=A2+1"}}, "message": "one more"})126+ if wrote["suggestion"] != 1.0 || wrote["saved"] != false {127+ t.Fatalf("write %v", wrote)128+ }129+ focus := callTool(t, cs, "focus", map[string]any{"ref": "C3"})130+ if focus["at"] != "Sheet1!C3" {131+ t.Fatalf("focus %v", focus)132+ }133+ ann.Do(func(w *sheet.Workbook) {134+ if err := BoardOf(ann).Accept(w, 1, nil); err != nil {135+ t.Error(err)136+ }137+ })138+ if v := value(ann, "Sheet1", "B1"); v.Num != 11 {139+ t.Fatalf("B1 is %v", v)140+ }141+ select {142+ case uri := <-updated:143+ if uri != "o12://live/suggestions" {144+ t.Errorf("updated %s", uri)145+ }146+ case <-time.After(5 * time.Second):147+ t.Error("the agent wasn't told its suggestion was accepted")148+ }149+ list := callTool(t, cs, "suggestions", nil)150+ if s := list["suggestions"].([]any)[0].(map[string]any); s["state"] != "accepted" {151+ t.Fatalf("suggestions %v", list)152+ }153+ undone := callTool(t, cs, "undo", nil)154+ if undone["undone"] != "set" {155+ t.Fatalf("undo %v", undone)156+ }157+ cs.Close()158+ deadline := time.Now().Add(5 * time.Second)159+ for len(peers) > 0 && time.Now().Before(deadline) {160+ ann.Do(func(*sheet.Workbook) { peers = ann.Peers() })161+ time.Sleep(5 * time.Millisecond)162+ }163+ if len(peers) != 0 {164+ t.Fatal("the agent didn't leave with its connection")165+ }166+}167+168+// The endpoint is listed for 012 mcp --attach, and goes with the169+// listener.170+func TestEndpointsListSessions(t *testing.T) {171+ _, l := listening(t)172+ dir := filepath.Dir(l.Socket())173+ eps, err := Endpoints(dir)174+ if err != nil || len(eps) != 1 || eps[0].Workbook != "budget" || eps[0].PID != os.Getpid() {175+ t.Fatalf("endpoints %+v, %v", eps, err)176+ }177+ if ep, _, err := Pick(eps, ""); err != nil || ep.Socket != l.Socket() {178+ t.Fatalf("picking the only one: %v", err)179+ }180+ if ep, _, err := Pick(eps, "budget.012"); err != nil || ep.Socket != l.Socket() {181+ t.Fatalf("picking by name: %v", err)182+ }183+ if _, _, err := Pick(eps, "plan"); err == nil || !strings.Contains(err.Error(), "budget (pid") {184+ t.Fatalf("picking one not there: %v", err)185+ }186+ if _, _, err := Pick(append(eps, Endpoint{Workbook: "plan", Kind: "session"}), ""); err == nil || !strings.Contains(err.Error(), "several") {187+ t.Fatalf("picking among two: %v", err)188+ }189+ if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 {190+ t.Fatalf("the folder: %v %v", info.Mode(), err)191+ }192+ l.Close()193+ if eps, _ := Endpoints(dir); len(eps) != 0 {194+ t.Fatalf("still listed: %+v", eps)195+ }196+}197+198+// A socket others can reach isn't attached to.199+func TestAttachRefusesSocketsOthersReach(t *testing.T) {200+ _, l := listening(t)201+ os.Chmod(l.Socket(), 0o666)202+ if err := checkOwner(l.Socket()); err == nil {203+ t.Fatal("a socket anyone can reach passed")204+ }205+}206+207+// A hello that isn't 012 mcp --attach's is refused.208+func TestListenerRefusesStrangers(t *testing.T) {209+ _, l := listening(t)210+ conn, err := net.Dial("unix", l.Socket())211+ if err != nil {212+ t.Fatal(err)213+ }214+ defer conn.Close()215+ conn.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}` + "\n"))216+ line, _ := bufio.NewReader(conn).ReadBytes('\n')217+ if !strings.Contains(string(line), "012 mcp --attach") {218+ t.Fatalf("got %s", line)219+ }220+}
A internal/cowork/peer_bsd.go +15 -0
A internal/cowork/peer_linux.go +13 -0
A internal/cowork/peer_other.go +12 -0
A internal/cowork/peer_unix.go +56 -0
M internal/mcp/books.go +13 -0
A internal/mcp/live.go +170 -0
@@ -0,0 +1,170 @@
1+package mcp2+3+import (4+ "context"5+ "encoding/json"6+7+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"8+9+ "github.com/FelineStateMachine/012/internal/diff"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Live mode (docs/agents/live.md): the server runs inside a 012 session14+// with its workbook on someone's screen, and the agent is one of the15+// participants of the session's room. Every tool works on that one16+// workbook, whose Backend is a Live: reads see it as it is on the17+// screen, and a change is the agent's, a suggestion the person accepts18+// or rejects unless they let agents edit directly. Live mode adds the19+// tools only a live workbook has: focus, undo, ask and suggestions.20+21+// Live is a workbook open in a session, as the agent attached to it22+// works on it.23+type Live interface {24+ Backend25+ // Make makes a change as the agent's, or suggests it.26+ Make(ctx context.Context, m Making) (Made, error)27+ // Focus moves the agent's pointer to ref, for the person to see28+ // where it's looking, and says where that is.29+ Focus(ctx context.Context, ref string) (string, error)30+ // Undo takes back the agent's latest step, saying what it was.31+ Undo(ctx context.Context) (string, error)32+ // Ask asks the person a question, as an elicitation request asks a33+ // host's user, and returns their answer.34+ Ask(ctx context.Context, p *sdk.ElicitParams) (*sdk.ElicitResult, error)35+ // Suggestions are the agent's suggestions and what became of them.36+ Suggestions(ctx context.Context) ([]Suggestion, error)37+ // RunCell has the person's session run a notebook cell, once they38+ // allow it, and says what happens next.39+ RunCell(ctx context.Context, notebook string, cell int) (string, error)40+ // Scope is what the person let the agent change, in words.41+ Scope() string42+}43+44+// Making is a change a tool asks for: its label, the agent's message45+// shown with it, whether only to see it, and the change itself.46+type Making struct {47+ Label, Message string48+ DryRun bool49+ Fn func(*sheet.Workbook) error50+}51+52+// Made is what came of a change: what it changes, whether it was made53+// on the workbook or waits as a suggestion, and news of the agent's54+// earlier suggestions.55+type Made struct {56+ Changes []diff.Change57+ Applied bool58+ Suggestion int59+ Notices []string60+}61+62+// Suggestion is one of the agent's suggestions as the suggestions tool63+// lists it.64+type Suggestion struct {65+ ID int `json:"id"`66+ Label string `json:"label"`67+ Message string `json:"message,omitempty"`68+ State string `json:"state" jsonschema:"pending, accepted, accepted in part or rejected"`69+ Cells int `json:"cells" jsonschema:"the cells it sets"`70+ Accepted int `json:"accepted" jsonschema:"the cells the person accepted"`71+ Rejected int `json:"rejected" jsonschema:"the cells the person rejected"`72+}73+74+const liveInstructions = `This server is attached to a workbook open on a person's screen in 012 (live mode): every tool works on that one workbook, so leave path out. Reads see it as it is now, the person's edits included.75+Your changes are suggestions unless the person lets agents edit directly: the cells you'd change are marked on their screen, and they accept or reject them, whole or cell by cell. A write returns the suggestion's number; the suggestions tool says what became of each, and news of them also comes back with your next write. Pass message with a write to tell the person why.76+Call focus to move your pointer where you're working, so the person sees where you're looking. undo takes back your own latest change, never the person's. ask asks the person a question and waits for the answer, as elicitation does: use it before anything they might not expect.77+You may change only what the person let you (the scope); a change outside it is refused with the reason.`78+79+type focusIn struct {80+ Ref string `json:"ref" jsonschema:"the cell or range to point at: B7, Q3!A1:C9, a named range or a table"`81+}82+83+type focusOut struct {84+ At string `json:"at" jsonschema:"where the pointer is, with its sheet"`85+}86+87+type undoOut struct {88+ Undone string `json:"undone" jsonschema:"what the step undone did"`89+}90+91+type askIn struct {92+ Message string `json:"message" jsonschema:"the question, shown to the person"`93+ RequestedSchema map[string]any `json:"requestedSchema,omitempty" jsonschema:"what to ask for, as an elicitation request's: an object schema of top-level string, number, integer and boolean properties, strings with enum for a choice; none for a yes or no question"`94+}95+96+type askOut struct {97+ Action string `json:"action" jsonschema:"accept, decline or cancel, as an elicitation's answer"`98+ Content map[string]any `json:"content,omitempty" jsonschema:"with accept, the answers by property"`99+}100+101+type suggestionsOut struct {102+ Scope string `json:"scope" jsonschema:"what the person let you change"`103+ Suggestions []Suggestion `json:"suggestions"`104+}105+106+type liveRunOut struct {107+ Status string `json:"status"`108+}109+110+// SuggestionsURI is the resource listing the agent's suggestions, as111+// the suggestions tool does; clients subscribed to it are told when the112+// person settles one (SuggestionsChanged).113+const SuggestionsURI = scheme + "live/suggestions"114+115+// SuggestionsChanged tells clients subscribed to SuggestionsURI that116+// the person settled a suggestion.117+func (s *Server) SuggestionsChanged(ctx context.Context) {118+ s.ResourceUpdated(ctx, &sdk.ResourceUpdatedNotificationParams{URI: SuggestionsURI})119+}120+121+// addLiveTools adds the tools of a live workbook, and its suggestions122+// resource.123+func (s *Server) addLiveTools(l Live) {124+ s.AddResource(&sdk.Resource{URI: SuggestionsURI, Name: "suggestions", MIMEType: "application/json",125+ Description: "Your suggestions and what became of each, as the suggestions tool lists them; subscribe to hear when the person settles one"},126+ func(ctx context.Context, _ *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {127+ list, err := l.Suggestions(ctx)128+ if err != nil {129+ return nil, err130+ }131+ data, _ := json.Marshal(suggestionsOut{Scope: l.Scope(), Suggestions: list})132+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: SuggestionsURI, MIMEType: "application/json", Text: string(data)}}}, nil133+ })134+ tool(s, &sdk.Tool{Name: "focus", Annotations: &sdk.ToolAnnotations{IdempotentHint: true, OpenWorldHint: ptr(false)},135+ Description: "Move your pointer to a cell or range, where the person sees it with your name, to show where you're working."},136+ func(ctx context.Context, _ *sdk.CallToolRequest, in focusIn) (*sdk.CallToolResult, focusOut, error) {137+ at, err := l.Focus(ctx, in.Ref)138+ return nil, focusOut{At: at}, err139+ })140+ tool(s, &sdk.Tool{Name: "undo", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},141+ Description: "Take back your own latest change that was made (accepted, or made directly), as Edit > Undo does for the person: never theirs."},142+ func(ctx context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, undoOut, error) {143+ label, err := l.Undo(ctx)144+ return nil, undoOut{Undone: label}, err145+ })146+ tool(s, &sdk.Tool{Name: "ask", Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: ptr(false)},147+ Description: "Ask the person a question on their screen and wait for the answer: yes or no, a choice, or values, as an elicitation request asks. Use it before a change they might not expect (\"Overwrite B2:B40?\")."},148+ func(ctx context.Context, _ *sdk.CallToolRequest, in askIn) (*sdk.CallToolResult, askOut, error) {149+ res, err := l.Ask(ctx, &sdk.ElicitParams{Message: in.Message, RequestedSchema: in.RequestedSchema})150+ if err != nil {151+ return nil, askOut{}, err152+ }153+ return nil, askOut{Action: res.Action, Content: res.Content}, nil154+ })155+ tool(s, &sdk.Tool{Name: "suggestions", Annotations: readOnly,156+ Description: "Your suggestions and what became of each: pending, accepted, accepted in part or rejected, with how many cells; and what the person let you change."},157+ func(ctx context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, suggestionsOut, error) {158+ list, err := l.Suggestions(ctx)159+ if list == nil {160+ list = []Suggestion{}161+ }162+ return nil, suggestionsOut{Scope: l.Scope(), Suggestions: list}, err163+ })164+ tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},165+ Description: "Have the person's session run a notebook tab's code cell with nushell, once they allow it: its output arrives in the workbook as when they run it, to read with describe or read_range."},166+ func(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, liveRunOut, error) {167+ status, err := l.RunCell(ctx, in.Notebook, in.Cell)168+ return nil, liveRunOut{Status: status}, err169+ })170+}
M internal/mcp/read.go +1 -1
M internal/mcp/roots.go +9 -1
M internal/mcp/server.go +9 -2
M internal/mcp/write.go +35 -10
@@ -13,20 +13,28 @@ import (
1313 // Change, so one undo step and one save, and returns what changed as1414 // 012 diff lists it; dry_run returns it without keeping it.1515 16+// whyIn is a change's message, in live mode.17+type whyIn struct {18+ Message string `json:"message,omitempty" jsonschema:"live mode: why, shown to the person with your suggestion"`19+}20+1621 type writeIn struct {1722 bookIn23+ whyIn1824 Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input, typed as a person types it in en-US form; an empty input clears the cell"`1925 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`2026 }2127 2228 type opsIn struct {2329 bookIn30+ whyIn2431 Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`2532 DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`2633 }2734 2835 type sortIn struct {2936 bookIn37+ whyIn3038 Ref string `json:"ref" jsonschema:"the range to sort"`3139 Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`3240 Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`
@@ -35,6 +43,7 @@ type sortIn struct {
3543 3644 type filterIn struct {3745 bookIn46+ whyIn3847 Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`3948 Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`4049 Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`
@@ -43,12 +52,14 @@ type filterIn struct {
4352 4453 type chartIn struct {4554 bookIn55+ whyIn4656 headless.ChartSpec4757 DryRun bool `json:"dry_run,omitempty"`4858 }4959 5060 type pivotIn struct {5161 bookIn62+ whyIn5263 headless.PivotSpec5364 DryRun bool `json:"dry_run,omitempty"`5465 }
@@ -65,6 +76,10 @@ type Changed struct {
6576 Saved bool `json:"saved"`6677 Changes []headless.Change `json:"changes"`6778 Warnings []string `json:"warnings"`79+ // Suggestion and Notices are live mode's: the change waiting for80+ // the person, and what became of earlier ones.81+ Suggestion int `json:"suggestion,omitempty" jsonschema:"live mode: the suggestion's number, waiting for the person to accept or reject it"`82+ Notices []string `json:"notices,omitempty" jsonschema:"live mode: what became of your earlier suggestions"`6883 }6984 7085 type chartOut struct {
@@ -85,7 +100,7 @@ func (s *Server) addWriteTools() {
85100 Description: "Type entries into cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take."},86101 func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {87102 var warnings []string88- return s.change(ctx, req, in.Path, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {103+ return s.change(ctx, req, in.Path, "set", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {89104 warnings, err = headless.Set(w, in.Entries, set)90105 return err91106 })
@@ -94,19 +109,19 @@ func (s *Server) addWriteTools() {
94109 Description: "Make several operations as one change: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},95110 func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {96111 var warnings []string97- return s.change(ctx, req, in.Path, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {112+ return s.change(ctx, req, in.Path, "apply operations", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {98113 warnings, err = headless.Apply(w, in.Operations, set)99114 return err100115 })101116 })102117 tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},103118 func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {104- return s.change(ctx, req, in.Path, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })119+ return s.change(ctx, req, in.Path, "sort", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })105120 })106121 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,107122 Description: "Put a filter on a range, hiding the rows its columns' conditions or values leave out, as Data > Create a filter does; or remove the sheet's filter."},108123 func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {109- return s.change(ctx, req, in.Path, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })124+ return s.change(ctx, req, in.Path, "filter", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })110125 })111126 tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),112127 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},
@@ -115,13 +130,16 @@ func (s *Server) addWriteTools() {
115130 Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},116131 func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {117132 var out pivotOut118- _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {133+ _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {119134 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)120135 return err121136 })122137 out.Changed = changed123138 return nil, out, err124139 })140+ if s.opts.Live != nil {141+ return // the live tools run notebook cells; there are no files to create142+ }125143 s.addCreateTool()126144 if s.opts.Notebooks != nil {127145 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},
@@ -137,7 +155,7 @@ func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in c
137155 if err != nil {138156 return nil, out, err139157 }140- _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {158+ _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {141159 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {142160 return err143161 }
@@ -157,7 +175,7 @@ func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCe
157175 if err != nil {158176 return nil, out, err159177 }160- _, _, err = s.change(ctx, req, in.Path, "run notebook cell", false, nil, func(w *sheet.Workbook) error {178+ _, _, err = s.change(ctx, req, in.Path, "run notebook cell", "", false, nil, func(w *sheet.Workbook) error {161179 if s.opts.MayRun != nil {162180 if err := s.opts.MayRun(b.path, w); err != nil {163181 return err
@@ -172,16 +190,23 @@ func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCe
172190 173191 // change makes fn a change of the workbook path names and says what it174192 // changed; warnings, when set, are fn's.175-func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {193+func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label, message string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {176194 b, err := s.open(ctx, req, path)177195 if err != nil {178196 return nil, Changed{}, err179197 }180- changes, err := b.Change(ctx, label, dryRun, fn)198+ var made Made199+ if l, ok := b.Backend.(Live); ok {200+ made, err = l.Make(ctx, Making{Label: label, Message: message, DryRun: dryRun, Fn: fn})201+ } else {202+ made.Changes, err = b.Change(ctx, label, dryRun, fn)203+ made.Applied = !dryRun204+ }181205 if err != nil {182206 return nil, Changed{}, err183207 }184- out := Changed{Saved: !dryRun && len(changes) > 0, Changes: headless.Changes(changes), Warnings: []string{}}208+ out := Changed{Saved: made.Applied && len(made.Changes) > 0, Changes: headless.Changes(made.Changes), Warnings: []string{},209+ Suggestion: made.Suggestion, Notices: made.Notices}185210 if warnings != nil && *warnings != nil {186211 out.Warnings = *warnings187212 }
M internal/room/registry.go +20 -2
@@ -44,10 +44,18 @@ func (g *Registry) Rooms() int {
4444 // reports whether it opened the room. A room's history is shared: undo4545 // takes back each participant's own steps.4646 func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {47+ return g.join(key, p, book)48+}49+50+// join is Join; with book nil, it seats p only in a room that's open.51+func (g *Registry) join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {4752 g.mu.Lock()4853 defer g.mu.Unlock()4954 r := g.rooms[key]5055 created := false56+ if r == nil && book == nil {57+ return nil, false58+ }5159 if r == nil {5260 w := book()5361 w.ShareHistory()
@@ -60,6 +68,9 @@ func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook)
6068 defer r.mu.Unlock()6169 r.nextID++6270 s := &Seat{room: r, id: r.nextID, name: p.Name(), color: r.freeColor(), p: p, joined: time.Now()}71+ if _, ok := p.(Agent); ok {72+ s.agent = true73+ }6374 if len(r.seats) == 0 {6475 r.writer = s.id6576 }
@@ -68,6 +79,13 @@ func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook)
6879 return s, created6980 }7081 82+// JoinOpen seats p in the room with key if it's open, reporting false83+// when it isn't: the agent joins only a workbook someone has open.84+func (g *Registry) JoinOpen(key string, p Participant) (*Seat, bool) {85+ s, _ := g.join(key, p, nil)86+ return s, s != nil87+}88+7189 // freeColor is the first color no one in the room has.7290 func (r *Room) freeColor() int {7391 used := map[int]bool{}
@@ -92,14 +110,14 @@ func (g *Registry) alias(r *Room, key string) {
92110 }93111 }94112 95-// close forgets the room once its last seat has left, reporting whether113+// close forgets the room once its last person has left, reporting whether96114 // it did: someone may have joined since.97115 func (g *Registry) close(r *Room) bool {98116 g.mu.Lock()99117 defer g.mu.Unlock()100118 r.mu.Lock()101119 defer r.mu.Unlock()102- if len(r.seats) > 0 || r.closed {120+ if r.keeper() != nil || r.closed {103121 return false104122 }105123 r.closed = true
M internal/room/room.go +74 -17
@@ -7,9 +7,10 @@
77 // its undo steps), each attributed to its author, as a stream of88 // operations (sheet.Op) the room hands every participant. A participant99 // is anything with a name that can be told the room changed10-// (Participant): a served session's model. The interface stays that11-// small so an MCP client attached to the server can take part the same12-// way (ROADMAP.md, Agents). See docs/contributing/architecture.md.10+// (Participant): a served session's model, a local session sharing its11+// workbook with the agent, or the agent itself, an MCP client attached in12+// live mode (internal/cowork, the Agent interface). See13+// docs/contributing/architecture.md.1314 package room1415 1516 import (
@@ -33,6 +34,14 @@ type Participant interface {
3334 Notify()3435 }3536 37+// Agent is a participant that is one of the agents (live mode, an MCP client38+// attached): the others see it marked as one, and it keeps nothing the39+// room runs.40+type Agent interface {41+ Participant42+ Agent()43+}44+3645 // Mode is who may change a room's workbook.3746 type Mode int3847
@@ -103,6 +112,7 @@ type Seat struct {
103112 presence Presence104113 moved bool // presence changed this turn105114 left bool115+ agent bool106116 }107117 108118 // Peer is what a participant sees of another: who, in what color,
@@ -114,6 +124,7 @@ type Peer struct {
114124 Presence Presence115125 Writing bool116126 Joined time.Time127+ Agent bool117128 }118129 119130 // Mark is an operation's place in the workbook: who changed which
@@ -243,12 +254,46 @@ func (s *Seat) Peers() []Peer {
243254 }244255 245256 func (s *Seat) peer() Peer {246- return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined}257+ return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined, Agent: s.agent}258+}259+260+// Agent reports whether the participant is one of the agents.261+func (s *Seat) Agent() bool { return s.agent }262+263+// Closed reports whether the room closed: its last person left. Call264+// it in a turn.265+func (s *Seat) Closed() bool { return s.room.closed }266+267+// Touch tells the others, once the turn ends, that something they268+// share changed without the workbook changing: a value of the room's269+// (Value), such as the agent's suggestions. Call it in a turn.270+func (s *Seat) Touch() { s.moved = true }271+272+// Peer is the participant with id, and whether they're here. Call it in273+// a turn.274+func (s *Seat) Peer(id int) (Peer, bool) {275+ if o := s.room.seat(id); o != nil {276+ return o.peer(), true277+ }278+ return Peer{}, false247279 }248280 249281 // Others is how many others are in the room. Call it in a turn.250282 func (s *Seat) Others() int { return len(s.room.seats) - 1 }251283 284+// People is how many other people are in the room, agents left out:285+// those who keep the workbook open when this one leaves. Call it in a286+// turn.287+func (s *Seat) People() int {288+ n := 0289+ for _, o := range s.room.seats {290+ if o != s && !o.agent {291+ n++292+ }293+ }294+ return n295+}296+252297 // Marks are the operations since seq, oldest first, whoever made them.253298 // Call it in a turn.254299 func (s *Seat) Marks(since uint64) []Mark {
@@ -319,9 +364,20 @@ func (s *Seat) Value(key string, make func() any) any {
319364 }320365 321366 // Keeper reports whether the participant keeps what the room runs on322-// its own (linked files followed, notebook cells running): the one who323-// has been there longest. Call it in a turn.324-func (s *Seat) Keeper() bool { return len(s.room.seats) > 0 && s.room.seats[0] == s }367+// its own (linked files followed, notebook cells running): the person368+// who has been there longest, as agents run nothing of the room's. Call369+// it in a turn.370+func (s *Seat) Keeper() bool { return s.room.keeper() == s }371+372+// keeper is the person there longest, nil when only agents are.373+func (r *Room) keeper() *Seat {374+ for _, s := range r.seats {375+ if !s.agent {376+ return s377+ }378+ }379+ return nil380+}325381 326382 // Post leaves msg for the keeper, whoever it is when it takes it: what327383 // the room runs reports back this way rather than to the participant
@@ -331,11 +387,12 @@ func (s *Seat) Post(msg any) {
331387 r := s.room332388 r.mu.Lock()333389 defer r.mu.Unlock()334- if r.closed || len(r.seats) == 0 {390+ k := r.keeper()391+ if r.closed || k == nil {335392 return336393 }337394 r.inbox = append(r.inbox, msg)338- r.seats[0].p.Notify()395+ k.p.Notify()339396 }340397 341398 // Take is what was posted for the keeper, if this is the keeper. Call
@@ -349,8 +406,10 @@ func (s *Seat) Take() []any {
349406 return in350407 }351408 352-// Leave gives up the seat, reporting whether it was the last: the room353-// closes then, and its workbook is the participant's alone.409+// Leave gives up the seat, reporting whether it was the last person410+// there: the room closes then, and its workbook is the participant's411+// alone. Agents still there are told, and find the room closed412+// (Closed).354413 func (s *Seat) Leave() (last bool) {355414 r := s.room356415 r.mu.Lock()
@@ -361,13 +420,11 @@ func (s *Seat) Leave() (last bool) {
361420 s.left = true362421 i := slices.Index(r.seats, s)363422 r.seats = slices.Delete(r.seats, i, i+1)364- empty := len(r.seats) == 0365- if !empty {366- if r.mode == View && r.writer == s.id {367- r.writer = r.seats[0].id368- }369- r.notify(nil)423+ empty := r.keeper() == nil424+ if !empty && r.mode == View && r.writer == s.id {425+ r.writer = r.keeper().id370426 }427+ r.notify(nil)371428 r.mu.Unlock()372429 if empty {373430 return r.reg.close(r)
M internal/room/room_test.go +50 -0
@@ -167,3 +167,53 @@ func TestRoomTurnsAtOnce(t *testing.T) {
167167 }168168 })169169 }170+171+// bot is one of the agents participant.172+type bot struct{ who }173+174+func (*bot) Agent() {}175+176+// The agent is marked as one to the others, keeps nothing the room runs177+// even when it was there first, and a touch tells the others.178+func TestRoomAgents(t *testing.T) {179+ g := NewRegistry(Edit)180+ claude := &bot{who{name: "claude"}}181+ c, _ := g.Join("k", claude, newBook)182+ ann := &who{name: "ann"}183+ a, _ := g.Join("k", ann, nil)184+ a.Do(func(*sheet.Workbook) {185+ peers := a.Peers()186+ if len(peers) != 1 || !peers[0].Agent || a.Agent() || !c.Agent() {187+ t.Errorf("peers %+v", peers)188+ }189+ if !a.Keeper() || c.Keeper() {190+ t.Error("the agent keeps the room")191+ }192+ if p, ok := a.Peer(c.ID()); !ok || p.Name != "claude" {193+ t.Errorf("peer %+v", p)194+ }195+ })196+ c.Post("ran")197+ var got []any198+ a.Do(func(*sheet.Workbook) { got = a.Take() })199+ if len(got) != 1 {200+ t.Errorf("ann took %v", got)201+ }202+ before := ann.kicks.Load()203+ c.Do(func(*sheet.Workbook) { c.Touch() })204+ if ann.kicks.Load() == before {205+ t.Error("a touch didn't tell ann")206+ }207+ // The last person out closes the room, agents or not.208+ if !a.Leave() || g.Has("k") {209+ t.Error("ann, the last person, didn't close the room")210+ }211+ c.Do(func(*sheet.Workbook) {212+ if !c.Closed() {213+ t.Error("the agent's room isn't closed")214+ }215+ })216+ if _, ok := g.JoinOpen("k", &bot{who{name: "late"}}); ok {217+ t.Error("the agent joined a closed room")218+ }219+}
A internal/serve/agents.go +44 -0
M internal/sheet/authors.go +16 -0
A internal/sheet/proposal.go +279 -0
@@ -0,0 +1,279 @@
1+package sheet2+3+import (4+ "bytes"5+ "cmp"6+ "errors"7+ "slices"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// Proposals: a change made on a copy of a workbook, to be made on the13+// workbook itself later, whole or cell by cell, or not at all. An14+// agent's suggestion in live mode is one (docs/agents/live.md): the15+// person sees the cells it would change and accepts some or all of16+// them, and what's accepted is one step, its author the workbook's17+// author when it's applied, so undo and the others' marks follow as18+// for any step.19+//20+// A change that does more than set cells (inserting rows, adding a21+// sheet or a chart, a filter) is made whole only, by running it again22+// on the workbook; a change of cells alone is applied as the cells it23+// made, so what's accepted is what was shown.24+25+// Proposal is a change made on a copy of a workbook.26+type Proposal struct {27+ Label string28+ // Cells are the cells whose contents, format or note the change29+ // sets, by sheet in the copy's order, then row and column.30+ Cells []Proposed31+ // Other says what the change does besides setting cells, one32+ // phrase each ("inserts or deletes rows or columns on Sheet1"); a33+ // proposal with any is made whole only.34+ Other []string35+ // Sheets are the sheets, by name, whose lines, charts, rules,36+ // filters or pivot the change changes; Book is set when it changes37+ // the sheet list, the settings, the names or the macros.38+ Sheets []string39+ Book bool40+ // Remote is set when a formula it enters asks a hosted model (JEV),41+ // which reaches the network once the change is made.42+ Remote bool43+44+ copy *Workbook45+ fn func(*Workbook) error46+}47+48+// Proposed is a cell a proposal sets: its sheet, where, its input49+// before and after ("" for blank), and whether its format, style or50+// note change too.51+type Proposed struct {52+ Sheet string53+ At Addr54+ Was, Now string55+ Look bool56+}57+58+// ErrWhole is returned when part of a proposal that must be made whole59+// is picked.60+var ErrWhole = errors.New("this change does more than set cells: it can only be accepted whole")61+62+// Propose makes fn, labelled label, on a copy of w and says what it63+// changed. w isn't changed; fn may be run again on it when the proposal64+// is applied whole.65+func Propose(w *Workbook, label string, fn func(*Workbook) error) (*Proposal, error) {66+ var data bytes.Buffer67+ if err := w.Write(&data); err != nil {68+ return nil, err69+ }70+ s, err := Read(&data)71+ if err != nil {72+ return nil, err73+ }74+ c := s.Book()75+ before := c.StateID()76+ if err := c.Batch(Change{Label: label, Sheet: c.Sheet(c.Active())}, func() error { return fn(c) }); err != nil {77+ return nil, err78+ }79+ p := &Proposal{Label: label, copy: c, fn: fn}80+ if st := c.hist.top(); st != nil && st.id != before {81+ p.collect(w, st)82+ }83+ return p, nil84+}85+86+// Empty reports whether the proposal changes nothing.87+func (p *Proposal) Empty() bool { return len(p.Cells) == 0 && len(p.Other) == 0 }88+89+// Whole reports whether the proposal can only be made whole.90+func (p *Proposal) Whole() bool { return len(p.Other) > 0 }91+92+// Result is the copy with the change made, to read what it would look93+// like. It must not be changed.94+func (p *Proposal) Result() *Workbook { return p.copy }95+96+// collect notes what step st made on the copy, against w.97+func (p *Proposal) collect(w *Workbook, st *step) {98+ order := map[string]int{}99+ for i, s := range p.copy.sheets {100+ order[s.name] = i101+ }102+ for s, img := range st.cells {103+ orig := w.Lookup(s.name)104+ img.addrs(func(a Addr) bool {105+ p.cell(orig, s, a)106+ return true107+ })108+ }109+ slices.SortFunc(p.Cells, func(x, y Proposed) int {110+ return cmp.Or(cmp.Compare(order[x.Sheet], order[y.Sheet]), cmp.Compare(x.At.Row, y.At.Row), cmp.Compare(x.At.Col, y.At.Col))111+ })112+ p.others(st)113+}114+115+// cell notes a's change on the copy's sheet s, when it changed from116+// what the workbook's sheet orig holds (nil: the sheet is new).117+func (p *Proposal) cell(orig, s *Sheet, a Addr) {118+ now := s.cells.get(a)119+ if now.Spilled() || now != nil && now.derived {120+ return // an array's or a pivot's: its formula is the change121+ }122+ var was *Cell123+ if orig != nil {124+ was = orig.cells.get(a)125+ }126+ in := func(c *Cell) string {127+ if c == nil {128+ return ""129+ }130+ return c.Input131+ }132+ look := func(c *Cell) (Format, Style, string) {133+ if c == nil {134+ return Format{}, Style{}, ""135+ }136+ return c.Format, c.Style, c.Note137+ }138+ wf, ws, wn := look(was)139+ nf, ns, nn := look(now)140+ lookChanged := wf != nf || ws != ns || wn != nn141+ if in(was) == in(now) && !lookChanged {142+ return143+ }144+ p.Cells = append(p.Cells, Proposed{Sheet: s.name, At: a, Was: in(was), Now: in(now), Look: lookChanged})145+ if now != nil && now.expr != nil && callsRemote(now.expr) {146+ p.Remote = true147+ }148+}149+150+// others notes what the step changed besides cells.151+func (p *Proposal) others(st *step) {152+ sheets := map[*Sheet]bool{}153+ add := func(s *Sheet) {154+ if !sheets[s] {155+ sheets[s] = true156+ p.Sheets = append(p.Sheets, s.name)157+ }158+ }159+ for s := range st.shifts {160+ add(s)161+ p.Other = append(p.Other, "inserts or deletes rows or columns on "+s.name)162+ }163+ for _, m := range []struct {164+ what string165+ on map[*Sheet]bool166+ }{{"charts", keysOf(st.charts)}, {"a pivot table", keysOf(st.pivots)}, {"rules", keysOf(st.rules)}, {"regions", keysOf(st.regions)}, {"the view", keysOf(st.views)}} {167+ for s := range m.on {168+ add(s)169+ p.Other = append(p.Other, "changes "+m.what+" on "+s.name)170+ }171+ }172+ lines := map[*Sheet]bool{}173+ for k := range st.widths {174+ lines[k.s] = true175+ }176+ for k := range st.heights {177+ lines[k.s] = true178+ }179+ for k := range st.lines {180+ lines[k.s] = true181+ }182+ for s := range lines {183+ add(s)184+ p.Other = append(p.Other, "changes columns' or rows' sizes or formats on "+s.name)185+ }186+ for _, b := range []struct {187+ what string188+ on bool189+ }{{"adds, deletes, renames or moves sheets", st.sheets != nil}, {"changes named ranges", len(st.names) > 0},190+ {"changes the settings", st.settings != nil}, {"changes the macros", st.macros != nil}} {191+ if b.on {192+ p.Book = true193+ p.Other = append(p.Other, b.what)194+ }195+ }196+ slices.Sort(p.Other)197+}198+199+// callsRemote reports whether a formula calls a function a hosted200+// model answers.201+func callsRemote(n Node) bool {202+ found := false203+ var walk func(Node)204+ walk = func(n Node) {205+ if call, ok := n.(formula.Call); ok && !found {206+ if f := funcOf(call); f != nil && f.Remote() {207+ found = true208+ }209+ }210+ formula.EachChild(n, walk)211+ }212+ walk(n)213+ return found214+}215+216+// Apply makes the proposal on w as one step, in the name of w's author:217+// the cells pick keeps (nil keeps them all), or the whole change run218+// again on w when it does more than set cells.219+func (p *Proposal) Apply(w *Workbook, pick func(i int) bool) error {220+ if p.Whole() {221+ for i := range p.Cells {222+ if pick != nil && !pick(i) {223+ return ErrWhole224+ }225+ }226+ return w.Batch(Change{Label: p.Label, Sheet: w.Sheet(w.Active())}, func() error { return p.fn(w) })227+ }228+ var picked []Proposed229+ for i, c := range p.Cells {230+ if pick == nil || pick(i) {231+ picked = append(picked, c)232+ }233+ }234+ if len(picked) == 0 {235+ return nil236+ }237+ first := w.Lookup(picked[0].Sheet)238+ if first == nil {239+ return errors.New(picked[0].Sheet + " is gone")240+ }241+ focus := Rect{From: picked[0].At, To: picked[0].At}242+ for _, c := range picked[1:] {243+ if c.Sheet == picked[0].Sheet {244+ focus = union(focus, Rect{From: c.At, To: c.At})245+ }246+ }247+ return w.Batch(Change{Label: p.Label, Sheet: first, Focus: focus}, func() error {248+ for _, c := range picked {249+ if err := p.place(w, c); err != nil {250+ return err251+ }252+ }253+ return nil254+ })255+}256+257+// place sets c's cell on w as the copy holds it.258+func (p *Proposal) place(w *Workbook, c Proposed) error {259+ s := w.Lookup(c.Sheet)260+ if s == nil {261+ return errors.New(c.Sheet + " is gone")262+ }263+ r := Rect{From: c.At, To: c.At}264+ if s.InPivot(r) {265+ return ErrPivotEdit266+ }267+ if _, ok := s.SpillAnchor(c.At); ok && c.Now != "" {268+ return ErrSpillEdit269+ }270+ src := p.copy.Lookup(c.Sheet).cells.get(c.At)271+ if src == nil {272+ if s.cells.has(c.At) {273+ s.place(c.At, nil)274+ }275+ return nil276+ }277+ s.place(c.At, src.clone())278+ return nil279+}
A internal/sheet/proposal_test.go +165 -0
@@ -0,0 +1,165 @@
1+package sheet2+3+import (4+ "errors"5+ "testing"6+)7+8+// proposalBook is a workbook with 1, 2, 3 in A1:A3 and their sum in A4,9+// its history shared by two authors, 1 the person and 2 the agent.10+func proposalBook(t *testing.T) *Workbook {11+ t.Helper()12+ w := New().Book()13+ s := w.Sheet(0)14+ for i, in := range []string{"1", "2", "3", "=SUM(A1:A3)"} {15+ if err := s.Set(Addr{Row: i}, in); err != nil {16+ t.Fatal(err)17+ }18+ }19+ w.ShareHistory()20+ return w21+}22+23+func setCells(entries map[Addr]string) func(*Workbook) error {24+ return func(w *Workbook) error {25+ for a, in := range entries {26+ if err := w.Sheet(0).Set(a, in); err != nil {27+ return err28+ }29+ }30+ return nil31+ }32+}33+34+func TestProposeLeavesTheWorkbook(t *testing.T) {35+ w := proposalBook(t)36+ state := w.StateID()37+ p, err := Propose(w, "set", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1, Col: 1}: "x"}))38+ if err != nil {39+ t.Fatal(err)40+ }41+ if w.StateID() != state || w.Sheet(0).Value(Addr{Row: 3}).Num != 6 {42+ t.Fatal("proposing changed the workbook")43+ }44+ if len(p.Cells) != 2 || p.Whole() || p.Remote {45+ t.Fatalf("cells %+v, other %v", p.Cells, p.Other)46+ }47+ if c := p.Cells[0]; c.At != (Addr{Row: 0}) || c.Was != "1" || c.Now != "10" {48+ t.Fatalf("first cell %+v", c)49+ }50+ if got := p.Result().Sheet(0).Value(Addr{Row: 3}).Num; got != 15 {51+ t.Fatalf("the copy's sum is %v", got)52+ }53+}54+55+func TestProposalAcceptedWholeIsOneStepOfItsAuthor(t *testing.T) {56+ w := proposalBook(t)57+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20"}))58+ w.SetAuthor(1)59+ w.Sheet(0).Set(Addr{Col: 2}, "mine")60+ w.SetAuthor(2)61+ if err := p.Apply(w, nil); err != nil {62+ t.Fatal(err)63+ }64+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 33 {65+ t.Fatalf("sum %v after accepting", got)66+ }67+ w.SetAuthor(2)68+ if w.UndoLabel() != "fix" {69+ t.Fatalf("the agent's undo is %q", w.UndoLabel())70+ }71+ w.Undo()72+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 6 || w.Sheet(0).Cell(Addr{Col: 2}).Input != "mine" {73+ t.Fatal("the agent's undo took back more than its step")74+ }75+}76+77+func TestProposalAcceptedByCell(t *testing.T) {78+ w := proposalBook(t)79+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20", {Row: 2}: ""}))80+ if len(p.Cells) != 3 {81+ t.Fatalf("cells %+v", p.Cells)82+ }83+ if err := p.Apply(w, func(i int) bool { return i != 1 }); err != nil {84+ t.Fatal(err)85+ }86+ s := w.Sheet(0)87+ if inputOf(s, Addr{Row: 0}) != "10" || inputOf(s, Addr{Row: 1}) != "2" || inputOf(s, Addr{Row: 2}) != "" {88+ t.Fatal("the picked cells weren't the ones set")89+ }90+ if got := s.Value(Addr{Row: 3}).Num; got != 12 {91+ t.Fatalf("sum %v", got)92+ }93+}94+95+func TestProposalRejectedChangesNothing(t *testing.T) {96+ w := proposalBook(t)97+ state := w.StateID()98+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10"}))99+ if err := p.Apply(w, func(int) bool { return false }); err != nil || w.StateID() != state {100+ t.Fatal("picking no cell made a step")101+ }102+}103+104+func TestProposalKeepsFormats(t *testing.T) {105+ w := proposalBook(t)106+ p, _ := Propose(w, "bold", func(w *Workbook) error {107+ w.Sheet(0).SetStyle(Rect{From: Addr{Row: 0}, To: Addr{Row: 0}}, func(st *Style) { st.Bold = true })108+ return nil109+ })110+ if len(p.Cells) != 1 || !p.Cells[0].Look || p.Cells[0].Was != p.Cells[0].Now {111+ t.Fatalf("cells %+v", p.Cells)112+ }113+ p.Apply(w, nil)114+ if !w.Sheet(0).Cell(Addr{Row: 0}).Style.Bold {115+ t.Fatal("the style wasn't applied")116+ }117+}118+119+func TestProposalOfRowsIsWhole(t *testing.T) {120+ w := proposalBook(t)121+ p, _ := Propose(w, "insert", func(w *Workbook) error { return w.Sheet(0).InsertRows(0, 1) })122+ if !p.Whole() || len(p.Sheets) != 1 || p.Sheets[0] != "Sheet1" {123+ t.Fatalf("other %v, sheets %v", p.Other, p.Sheets)124+ }125+ if err := p.Apply(w, func(int) bool { return false }); !errors.Is(err, ErrWhole) && len(p.Cells) > 0 {126+ t.Fatalf("part of a whole change: %v", err)127+ }128+ if err := p.Apply(w, nil); err != nil {129+ t.Fatal(err)130+ }131+ if w.Sheet(0).Cell(Addr{Row: 1}).Input != "1" {132+ t.Fatal("the row wasn't inserted")133+ }134+}135+136+func TestProposalNotesRemoteFormulas(t *testing.T) {137+ w := proposalBook(t)138+ p, _ := Propose(w, "ask", setCells(map[Addr]string{{Col: 1}: `=JEV.CLASSIFY(A1, "parity", "odd, even")`}))139+ if !p.Remote {140+ t.Fatalf("a JEV formula isn't remote: %+v", p.Cells)141+ }142+}143+144+func TestAdoptKeepsEarlierStepsTheirs(t *testing.T) {145+ w := New().Book()146+ w.Sheet(0).Set(Addr{}, "1")147+ w.ShareHistory()148+ w.Adopt(1)149+ w.SetAuthor(1)150+ if !w.CanUndo() {151+ t.Fatal("the adopted step isn't the author's")152+ }153+ w.SetAuthor(2)154+ if w.CanUndo() {155+ t.Fatal("another author can undo it")156+ }157+}158+159+// inputOf is the input at a, "" for a blank cell.160+func inputOf(s *Sheet, a Addr) string {161+ if c := s.Cell(a); c != nil {162+ return c.Input163+ }164+ return ""165+}
M internal/sheet/randedit_shared_test.go +52 -8
@@ -11,7 +11,9 @@ import (
1111 // Random edits by several participants: the edits of TestRandomEdits,1212 // each made by one of three authors drawn from the seed, interleaved in1313 // one workbook whose history is shared, as a room of 012 serve orders14-// them. Then the authors undo, in turns drawn from the seed, each only14+// them, and among them the agent's (live mode): its edits are proposals15+// (Propose), accepted whole, cell by cell or not at all, as drawn from16+// the seed. Then the authors, the agent among them, undo, in turns drawn from the seed, each only1517 // their own steps, from under the others' where nothing later overlaps1618 // them. What must hold:1719 //
@@ -41,8 +43,46 @@ func TestRandomSharedEdits(t *testing.T) {
4143 }4244 }4345 44-// randAuthors is how many participants edit.45-const randAuthors = 346+// randAuthors is how many people edit; the agent is one more.47+const (48+ randAuthors = 349+ randAgent = randAuthors + 150+)51+52+// suggest has the agent propose an edit, made on a copy, and accepts it53+// whole, some of its cells or none, as rng draws.54+func (r *randBook) suggest(e *edits, rng *rand.Rand) string {55+ from := *e56+ var label string57+ edit := func(w *Workbook) error {58+ b := r59+ if w != r.wb {60+ b = &randBook{wb: w, nb: w.Lookup(r.nb.Name()), feeds: maps.Clone(r.feeds)}61+ }62+ d := from63+ label = b.edit(&d)64+ *e = d65+ return nil66+ }67+ p, err := Propose(r.wb, "suggestion", edit)68+ if err != nil {69+ return fmt.Sprintf("%d suggests %q: %v", randAgent, label, err)70+ }71+ var pick func(int) bool72+ what := "accepted"73+ switch rng.IntN(3) {74+ case 0:75+ return fmt.Sprintf("%d suggests %q: rejected", randAgent, label)76+ case 1:77+ if !p.Whole() {78+ pick, what = func(int) bool { return rng.IntN(2) == 0 }, "accepted in part"79+ }80+ }81+ if err := p.Apply(r.wb, pick); err != nil {82+ what = err.Error()83+ }84+ return fmt.Sprintf("%d suggests %q: %s", randAgent, label, what)85+}4686 4787 func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string) {4888 e := &edits{data: data}
@@ -54,9 +94,13 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
5494 if e.done() {5595 break5696 }57- a := 1 + e.n(randAuthors)97+ a := 1 + e.n(randAuthors+1)5898 r.wb.SetAuthor(a)59- log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))99+ if a == randAgent {100+ log = append(log, r.suggest(e, rng))101+ } else {102+ log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))103+ }60104 feedStale(r.wb, r.feeds)61105 }62106 for undone := 0; ; undone++ {
@@ -100,10 +144,10 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
100144 // from rng, or reports false when nobody has a step left. It fails the101145 // run when steps are left but nobody can undo one.102146 func (r *randBook) nextUndo(rng *rand.Rand) (int, bool) {103- first := rng.IntN(randAuthors)147+ first := rng.IntN(randAgent)104148 left := false105- for i := range randAuthors {106- a := 1 + (first+i)%randAuthors149+ for i := range randAgent {150+ a := 1 + (first+i)%randAgent107151 r.wb.SetAuthor(a)108152 if !r.wb.CanUndo() {109153 continue
A internal/ui/agentask.go +153 -0
@@ -0,0 +1,153 @@
1+package ui2+3+import (4+ "strconv"5+6+ "github.com/charmbracelet/x/ansi"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/cowork"11+)12+13+// The agent's questions (the ask tool) and requests (to run notebook14+// cells, to enter JEV formulas directly) wait on the room's board until15+// the person it asks is free: in READY, nothing open. Then the question16+// takes the context line, a field at a time: a yes or no, a choice, or17+// a value typed as a prompt. Esc cancels; the answer goes back to the18+// agent as an elicitation's result.19+20+// agentAsks opens the next question waiting for this session, when the21+// person is free, and lets go of one the agent withdrew.22+func (m *Model) agentAsks() tea.Cmd {23+ seat := m.share.seat24+ if seat == nil {25+ return nil26+ }27+ if q := m.agents.asking; q != nil {28+ if q.Done() {29+ m.agents.asking = nil30+ m.closeAsk()31+ m.note = q.Agent + " stopped waiting for an answer"32+ }33+ return nil34+ }35+ if m.mode != modeReady || m.overlay != nil {36+ return nil37+ }38+ b := m.board()39+ q := b.NextAsk(seat.ID(), func(id int) bool {40+ _, here := seat.Peer(id)41+ return here || id == seat.ID()42+ })43+ if q == nil {44+ return nil45+ }46+ m.agents.asking = q47+ if q.Kind == cowork.Grant {48+ m.askGrant(q)49+ } else {50+ m.askField(q, 0, map[string]any{})51+ }52+ return nil53+}54+55+// closeAsk closes the question on the context line.56+func (m *Model) closeAsk() {57+ switch {58+ case m.mode == modePrompt:59+ m.closePrompt()60+ case m.overlay != nil:61+ m.closeOverlay()62+ }63+}64+65+// answerAsk sends the person's answer to the agent.66+func (m *Model) answerAsk(q *cowork.Ask, ans cowork.Answer) {67+ m.agents.asking = nil68+ if m.share.seat != nil {69+ m.board().Answer(q, ans)70+ }71+ switch ans.Action {72+ case "accept":73+ m.note = "Answered " + q.Agent74+ case "decline":75+ m.note = "Told " + q.Agent + " no"76+ default:77+ m.note = "Didn't answer " + q.Agent78+ }79+}80+81+// askHead is how a question starts on the context line, cut to leave82+// its choices room.83+func (m *Model) askHead(q *cowork.Ask, text string) string {84+ return ansi.Truncate("◆ "+q.Agent+" asks: "+text, max(m.width-44, 24), "…")85+}86+87+// askField asks field i of q, the answers so far in content; past the88+// last, the answer goes back.89+func (m *Model) askField(q *cowork.Ask, i int, content map[string]any) {90+ cancel := func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "cancel"}); return nil }91+ if len(q.Fields) == 0 {92+ m.ask(question{msg: m.askHead(q, q.Message), desc: q.Message, choices: []choice{93+ {key: "enter", label: "Yes", run: func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "accept"}); return nil }},94+ {key: "n", label: "No", run: func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "decline"}); return nil }},95+ {key: "esc", label: "Cancel", run: cancel},96+ }})97+ return98+ }99+ if i == len(q.Fields) {100+ m.answerAsk(q, cowork.Answer{Action: "accept", Content: content})101+ return102+ }103+ f := q.Fields[i]104+ head := m.askHead(q, q.Message+" "+f.Label())105+ if i > 0 {106+ head = m.askHead(q, f.Label())107+ }108+ next := func(v any) func(m *Model) tea.Cmd {109+ return func(m *Model) tea.Cmd {110+ content[f.Name] = v111+ m.askField(q, i+1, content)112+ return nil113+ }114+ }115+ switch f.Kind {116+ case cowork.Boolean:117+ m.ask(question{msg: head + "?", desc: f.Description, choices: []choice{118+ {key: "y", label: "Yes", run: next(true)}, {key: "n", label: "No", run: next(false)}, {key: "esc", label: "Cancel", run: cancel}}})119+ case cowork.Choice:120+ var chs []choice121+ for j := range f.Choices {122+ chs = append(chs, choice{key: strconv.Itoa(j + 1), label: f.ChoiceLabel(j), run: next(f.Choices[j])})123+ }124+ m.ask(question{msg: head, desc: f.Description, choices: append(chs, choice{key: "esc", label: "Cancel", run: cancel})})125+ default:126+ m.askText(head+":", "", func(text string) {127+ v, err := f.Parse(text)128+ if err != nil {129+ m.fail(err.Error())130+ m.askField(q, i, content)131+ return132+ }133+ next(v)(m)134+ }, func() { cancel(m) })135+ }136+}137+138+// askGrant asks leave for what the agent wants to do beyond changing139+// cells, once or for the session.140+func (m *Model) askGrant(q *cowork.Ask) {141+ answer := func(ans cowork.Answer) func(m *Model) tea.Cmd {142+ return func(m *Model) tea.Cmd { m.answerAsk(q, ans); return nil }143+ }144+ desc := "Notebook cells run with nu as you, with your files, as an untrusted macro would"145+ if q.Grant == "jev" {146+ desc = "JEV formulas send cells' values to TypeSafe's model with your API key"147+ }148+ m.ask(question{msg: ansi.Truncate("◆ "+q.Message+".", max(m.width-64, 20), "…"), warn: true, desc: desc, choices: []choice{149+ {key: "enter", label: "Allow once", run: answer(cowork.Answer{Action: "accept"})},150+ {key: "a", label: "Allow for this session", run: answer(cowork.Answer{Action: "accept", Always: true})},151+ {key: "esc", label: "Deny", run: answer(cowork.Answer{Action: "decline"})},152+ }})153+}
A internal/ui/agentdraw.go +144 -0
@@ -0,0 +1,144 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/cowork"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// Drawing agents' suggestions: each cell a suggestion waiting would set16+// has a ◇ in its top-left corner (theme.Suggested), the context line17+// says what the agent would put there and why when the pointer is on18+// one, and the status line counts those waiting. Agents themselves are19+// drawn as the others are (sharedraw.go), their initial a ◆.20+21+// agentFrame is what a frame draws of suggestions, worked out once as22+// it starts: the cells of the sheet shown that suggestions waiting set,23+// and how many suggestions wait.24+type agentFrame struct {25+ cells map[sheet.Addr]suggestedCell26+ waiting int27+}28+29+// suggestedCell is a cell of a suggestion.30+type suggestedCell struct {31+ s *cowork.Suggestion32+ i int33+}34+35+// frameAgents works out the suggestions for the frame about to be36+// drawn.37+func (m *Model) frameAgents() {38+ f := &m.agents.frame39+ clear(f.cells)40+ f.waiting = 041+ if m.share.seat == nil {42+ return43+ }44+ pending := m.board().Pending()45+ f.waiting = len(pending)46+ name := m.sheet.Name()47+ for _, s := range pending {48+ for i, c := range s.Cells {49+ if c.Sheet != name || !s.Whole() && s.States[i] != cowork.CellPending {50+ continue51+ }52+ if f.cells == nil {53+ f.cells = map[sheet.Addr]suggestedCell{}54+ }55+ f.cells[c.At] = suggestedCell{s, i}56+ }57+ }58+}59+60+// suggestedMark draws the ◇ of a cell a suggestion would set over the61+// first column of its text.62+func (m *Model) suggestedMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {63+ if _, ok := m.agents.frame.cells[a]; !ok || m.sheet.ColWidth(a.Col) < 2 {64+ return text65+ }66+ mark := m.th.Suggested67+ if colored {68+ mark = base.Bold(true)69+ }70+ return mark.Render("◇") + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))71+}72+73+// agentLine is the context line's word on a cell a suggestion would74+// set: whose, what it would put there, what's there now and why.75+func (m *Model) agentLine() string {76+ sc, ok := m.agents.frame.cells[m.cur]77+ if !ok {78+ return ""79+ }80+ s, c := sc.s, sc.s.Cells[sc.i]81+ what := shownInput(c.Now)82+ if c.Look && c.Was == c.Now {83+ what = "a new format"84+ }85+ line := m.th.Suggested.Render("◇ ") + m.th.Peer[s.Color%theme.Peers].Render(" ◆ "+s.Agent+" ") +86+ m.th.Muted.Render(" suggests ") + m.th.Key.Render(what) + m.th.Muted.Render(", now "+shownInput(c.Was))87+ hint := " " + m.th.KeyHints(keyLabel(keyFor("agent.review")), "review")88+ if s.Message != "" {89+ // The message gives way to the key, cut to what's left.90+ room := m.width - ansi.StringWidth(line) - ansi.StringWidth(hint) - 291+ if room > 3 {92+ line += m.th.Muted.Render(ansi.Truncate(": "+s.Message, room, "…"))93+ }94+ }95+ return line + hint96+}97+98+// keyFor is the first key bound to a command.99+func keyFor(id string) string {100+ if keys := keysFor(id, false); len(keys) > 0 {101+ return keys[0]102+ }103+ return ""104+}105+106+// shownInput is an input as live mode shows it: blank for none.107+func shownInput(in string) string {108+ if in == "" {109+ return "blank"110+ }111+ return in112+}113+114+// agentsStatus is the status line's count of suggestions waiting.115+func (m *Model) agentsStatus() string {116+ n := m.agents.frame.waiting117+ if n == 0 {118+ return ""119+ }120+ what := strconv.Itoa(n) + " suggestions"121+ if n == 1 {122+ what = "1 suggestion"123+ }124+ return " " + m.th.Suggested.Render("◇ "+what)125+}126+127+// peerInitial is what stands for a participant on a row header: an128+// agent's ◆, a person's initial.129+func peerInitial(name string, agent bool) string {130+ if agent {131+ return "◆"132+ }133+ return initial(name)134+}135+136+// peerLabel is a participant's name as the status line shows it, an137+// agent's after its ◆.138+func peerLabel(name string, agent bool) string {139+ name = ansi.Truncate(name, 10, "…")140+ if agent {141+ return "◆ " + name142+ }143+ return strings.TrimSpace(name)144+}
A internal/ui/agents.go +305 -0
@@ -0,0 +1,305 @@
1+package ui2+3+import (4+ "fmt"5+ "os/user"6+ "path/filepath"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/cowork"12+ "github.com/FelineStateMachine/012/internal/room"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/picker"15+ "github.com/FelineStateMachine/012/internal/ui/review"16+)17+18+// Live mode (docs/agents/live.md): the agent works in this session as a19+// participant of its room. A local session invited to (012 --listen,20+// File > Invite the agent) puts its workbook in a room of its own, with21+// its person the first seat, and listens on a socket for 012 mcp22+// --attach (package cowork); 012 serve's rooms are joined the same way.23+// The agent's changes wait on the room's board as suggestions, marked24+// in the grid (agentdraw.go) and settled in the review panel (package25+// review); its questions and requests show on the context line26+// (agentask.go).27+28+// agentState is the session's part in live mode.29+type agentState struct {30+ listener *cowork.Listener // the session's socket, nil when not listening31+ scope cowork.Scope // what the person chose, for the board once the room is joined32+ asking *cowork.Ask // the agent's question on the context line33+ frame agentFrame // what the frame draws of suggestions: agentdraw.go34+}35+36+func init() {37+ register(38+ &command{id: "agent.invite", macro: macroNever, title: "Invite an agent",39+ desc: "Let an agent on this computer work in this workbook with you (012 mcp --attach): pick what it may change",40+ hidden: func(m *Model) bool { return m.share.reg != nil && !m.share.local }, run: (*Model).openInvite},41+ &command{id: "agent.review", macro: macroNever, title: "Review suggestions",42+ desc: "List the agents' suggestions to accept or reject, whole or cell by cell",43+ hidden: noRoom, enabled: func(m *Model) bool { return len(m.board().Pending()) > 0 }, run: (*Model).openReview},44+ &command{id: "agent.direct", macro: macroNever, title: "Let agents edit directly",45+ desc: "Make agents' changes at once, as their own steps, rather than as suggestions to accept",46+ hidden: noRoom, checked: func(m *Model) bool { return m.board().Direct }, run: (*Model).toggleDirect},47+ &command{id: "agent.stop", macro: macroNever, title: "Stop inviting agents",48+ desc: "Close this session's socket: agents attached leave, their suggestions stay",49+ hidden: func(m *Model) bool { return m.agents.listener == nil }, run: func(m *Model) tea.Cmd { m.StopListening(); return nil }},50+ )51+ keymap["ctrl+alt+a"] = "agent.review"52+}53+54+// noRoom hides what needs a room: live mode's commands before the agent55+// is invited.56+func noRoom(m *Model) bool { return m.share.seat == nil }57+58+// board is the room's board of agents; nil outside a room. Call it in a59+// turn.60+func (m *Model) board() *cowork.Board {61+ if m.share.seat == nil {62+ return &cowork.Board{}63+ }64+ return cowork.BoardOf(m.share.seat)65+}66+67+// openInvite asks what the agent may change.68+func (m *Model) openInvite() tea.Cmd {69+ whole := picker.Item{Title: "The whole workbook", Desc: "It may change anything, suggested for you to accept unless you let agents edit directly",70+ Pick: m.inviting(cowork.Scope{Kind: cowork.Workbook})}71+ items := []picker.Item{whole}72+ if !m.sheet.IsNotebook() {73+ r := m.selection()74+ items = append(items,75+ picker.Item{Title: "This sheet", Detail: m.sheet.Name(), Desc: "It may change " + m.sheet.Name() + " only",76+ Pick: m.inviting(cowork.Scope{Kind: cowork.OneSheet, Sheet: m.sheet})},77+ picker.Item{Title: "The selection", Detail: r.String(), Desc: "It may change the cells of " + r.String() + " only",78+ Pick: m.inviting(cowork.Scope{Kind: cowork.OneRange, Sheet: m.sheet, Range: r})})79+ }80+ items = append(items, picker.Item{Title: "Read only", Desc: "It reads the workbook, points and asks you, and changes nothing",81+ Pick: m.inviting(cowork.Scope{Kind: cowork.ReadOnly})})82+ for i := range items {83+ items[i].Name = len(items[i].Title)84+ }85+ pk := m.newPicker("Invite an agent", "what it may change", 56, items)86+ pk.Action = "invite"87+ m.openOverlay(pk)88+ return nil89+}90+91+// inviting is a picker's choice of scope.92+func (m *Model) inviting(s cowork.Scope) func() tea.Cmd {93+ return func() tea.Cmd {94+ m.closeOverlay()95+ m.invite(s)96+ return nil97+ }98+}99+100+// invite lets agents in with scope s: at once when the session already101+// listens, else once it has joined its room (enterLocal).102+func (m *Model) invite(s cowork.Scope) {103+ m.agents.scope = s104+ if m.share.seat != nil {105+ m.board().Scope = s106+ m.share.seat.Touch()107+ if m.agents.listener == nil && m.share.local {108+ m.startListening(m.share.seat.Key())109+ }110+ m.note = "Agents may change " + s.String()111+ return112+ }113+ m.share.reg = room.NewRegistry(room.Edit)114+ m.share.link = &peerLink{name: userName(), kick: make(chan struct{}, 1)}115+ m.share.local = true116+ key := "@local"117+ if m.filename != "" {118+ if k, ok := m.roomKey(m.filename); ok {119+ key = k120+ }121+ }122+ m.share.want = &joinReq{key: key, name: m.filename, book: m.book(), local: true}123+}124+125+// Listen has the session listen for agents from the start, letting126+// them suggest changes to the whole workbook: 012 --listen.127+func (m *Model) Listen() { m.invite(cowork.Scope{Kind: cowork.Workbook}) }128+129+// StopListening closes the session's socket; agents attached leave.130+func (m *Model) StopListening() {131+ if m.agents.listener == nil {132+ return133+ }134+ m.agents.listener.Close()135+ m.agents.listener = nil136+ m.note = "Stopped inviting agents"137+}138+139+// userName is who the person is to agents.140+func userName() string {141+ if u, err := user.Current(); err == nil && u.Username != "" {142+ return filepath.Base(u.Username) // DOMAIN\name on Windows143+ }144+ return "you"145+}146+147+// enterLocal takes the local session's own workbook into its room, as148+// it is: nothing reopens, unsaved changes stay unsaved, and the steps149+// made so far stay the person's to undo.150+func (m *Model) enterLocal(seat *room.Seat, req *joinReq) tea.Cmd {151+ m.share.seat, m.share.seen = seat, seat.Last()152+ seat.Book().Adopt(seat.ID())153+ runs := m.nb.runs154+ m.nb.runs = seat.Value("nb", func() any { return runs }).(*nbRuns)155+ src := m.src.run156+ if src == nil {157+ src = &sourceHost{}158+ }159+ m.src.run = seat.Value("sources", func() any { return src }).(*sourceHost)160+ saved := room.Saved{State: m.saved, Outputs: m.nb.saved, Name: m.filename, Stamp: m.disk}161+ if m.filename != "" {162+ saved.Key = req.key163+ }164+ seat.SetSaved(saved)165+ b := cowork.BoardOf(seat)166+ b.Host, b.Scope = seat.ID(), m.agents.scope167+ m.startListening(req.key)168+ return m.waitRoom()169+}170+171+// startListening opens the session's socket for agents joining the room172+// with key.173+func (m *Model) startListening(key string) {174+ name := strings.TrimSuffix(filepath.Base(m.filename), filepath.Ext(m.filename))175+ if m.filename == "" {176+ name = "Untitled"177+ }178+ l, err := cowork.Listen(cowork.Options{Registry: m.share.reg, Room: func(string) (string, error) { return key, nil },179+ Kind: "session", Workbook: name, Version: m.prefs.Version})180+ if err != nil {181+ m.warn = "Can't listen for agents: " + err.Error()182+ return183+ }184+ m.agents.listener = l185+ m.note = fmt.Sprintf("Agents may change %s: attach with 012 mcp --attach %s", m.agents.scope, name)186+}187+188+// waitRoom waits for the room to change, in a local session, where no189+// server sends the program its roomMsg.190+func (m *Model) waitRoom() tea.Cmd {191+ if !m.share.local || m.share.still || m.share.waiting {192+ return nil193+ }194+ m.share.waiting = true195+ kick := m.share.link.kick196+ return func() tea.Msg {197+ <-kick198+ return roomMsg{}199+ }200+}201+202+// roomKicked catches up with the room, and waits for it again.203+func (m *Model) roomKicked() tea.Cmd {204+ m.share.waiting = false205+ return tea.Batch(m.roomChanged(), m.waitRoom())206+}207+208+// leftLocal stops listening when the session leaves its room: it opened209+// another file, and agents work on the one they were invited to.210+func (m *Model) leftLocal() {211+ if m.agents.listener != nil {212+ m.StopListening()213+ m.note = "Stopped inviting agents: they work on the workbook they were invited to"214+ }215+}216+217+// toggleDirect turns direct edits by agents on or off.218+func (m *Model) toggleDirect() tea.Cmd {219+ if m.share.seat == nil {220+ return nil221+ }222+ b := m.board()223+ b.Direct = !b.Direct224+ m.share.seat.Touch()225+ if b.Direct {226+ m.note = "Agents' changes are made at once, as their own steps: their undo takes them back"227+ } else {228+ m.note = "Agents' changes are suggestions for you to accept"229+ }230+ return nil231+}232+233+// openReview opens the suggestions panel.234+func (m *Model) openReview() tea.Cmd {235+ m.openOverlay(review.New(m.host()))236+ return nil237+}238+239+// Suggestions are the board's, for the panel.240+func (h host) Suggestions() []review.Item {241+ var out []review.Item242+ for _, s := range h.m.board().Pending() {243+ it := review.Item{ID: s.ID, Agent: s.Agent, Color: s.Color, Label: s.Label, Message: s.Message, Whole: s.Whole(),244+ Other: s.Other, Total: len(s.Cells), Failed: s.Failed}245+ for i, c := range s.Cells {246+ if s.Whole() || s.States[i] == cowork.CellPending {247+ it.Cells = append(it.Cells, review.Cell{Index: i, Sheet: c.Sheet, At: c.At, Was: c.Was, Now: c.Now, Look: c.Look})248+ }249+ }250+ out = append(out, it)251+ }252+ return out253+}254+255+func (h host) Accept(id int, cells []int) { h.m.settle(id, cells, true) }256+func (h host) Reject(id int, cells []int) { h.m.settle(id, cells, false) }257+258+// Reveal shows the cell a suggestion sets.259+func (h host) Reveal(name string, a sheet.Addr) {260+ if s := h.m.book().Lookup(name); s != nil && !s.Hidden() {261+ h.m.showSheet(s)262+ h.m.cur = a263+ h.m.clearSelection()264+ }265+}266+267+// settle accepts or rejects cells of suggestion id (nil: all it has268+// waiting). What's accepted is one step, the agent's.269+func (m *Model) settle(id int, cells []int, accept bool) {270+ b, seat := m.board(), m.share.seat271+ s := b.Get(id)272+ if s == nil || seat == nil {273+ return274+ }275+ what := "all of"276+ if cells != nil && !s.Whole() {277+ what = fmt.Sprintf("%d of %d cells of", len(cells), len(s.Cells))278+ }279+ if !accept {280+ b.Reject(id, cells)281+ seat.Touch()282+ m.note = "Rejected " + what + " " + s.Agent + "'s suggestion: " + s.Label283+ return284+ }285+ if !m.mayEdit() {286+ return287+ }288+ if err := b.Accept(m.book(), id, cells); err != nil {289+ m.warn = err.Error()290+ return291+ }292+ seat.Touch()293+ m.note = "Accepted " + what + " " + s.Agent + "'s suggestion: " + s.Label + " (" + s.Agent + "'s to undo)"294+}295+296+// agentRunCell runs the notebook cell the agent asked for, once allowed297+// (cowork.Agent.RunCell), as Run does.298+func (m *Model) agentRunCell(rc cowork.RunCell) tea.Cmd {299+ s := m.book().Lookup(rc.Notebook)300+ if s == nil || !s.IsNotebook() || rc.Cell >= len(s.NotebookCells()) {301+ return nil302+ }303+ m.note = fmt.Sprintf("%s runs cell %d of %s", rc.Agent, rc.Cell+1, rc.Notebook)304+ return m.runCells(s, rc.Cell, s.NotebookCells())305+}
A internal/ui/agents_test.go +288 -0
@@ -0,0 +1,288 @@
1+package ui2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/cowork"15+ "github.com/FelineStateMachine/012/internal/headless"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// liveSession is a local session listening for agents, and the agent in21+// its room, as 012 --listen and 012 mcp --attach make them.22+type liveSession struct {23+ *seshion24+ agent *cowork.Agent25+}26+27+// testAgent is one of the agents in the room without a connection.28+type testAgent struct{ kick chan struct{} }29+30+func (a *testAgent) Name() string { return "claude" }31+func (a *testAgent) Agent() {}32+func (a *testAgent) Notify() {33+ select {34+ case a.kick <- struct{}{}:35+ default:36+ }37+}38+39+// startLive opens a session on a workbook of 40, 2 and their sum in40+// A1:A3, listening for agents, with the agent attached.41+func startLive(t *testing.T) *liveSession {42+ t.Helper()43+ run, err := os.MkdirTemp("", "o12")44+ if err != nil {45+ t.Fatal(err)46+ }47+ t.Cleanup(func() { os.RemoveAll(run) })48+ t.Setenv("XDG_RUNTIME_DIR", run)49+ s := sheet.New()50+ for i, in := range []string{"40", "2", "=A1+A2"} {51+ s.Set(sheet.Addr{Row: i}, in)52+ }53+ s.Book().ClearHistory()54+ path := filepath.Join(t.TempDir(), "budget.012")55+ m := New(s, path)56+ m.SetMachine("here")57+ m.nb.still = true58+ sh := InRooms(Guard(m))59+ ls := &liveSession{seshion: &seshion{t: t, sh: sh, m: m}}60+ m.Listen()61+ m.share.still = true62+ ls.run(sh.Init())63+ ls.send(tea.WindowSizeMsg{Width: 80, Height: 20})64+ t.Cleanup(m.StopListening)65+ if m.share.seat == nil || m.agents.listener == nil {66+ t.Fatalf("not listening: %s", m.warn)67+ }68+ seat, ok := m.share.reg.JoinOpen(m.share.seat.Key(), &testAgent{kick: make(chan struct{}, 1)})69+ if !ok {70+ t.Fatal("the agent couldn't join")71+ }72+ ls.agent = cowork.NewAgent(seat)73+ ls.sync()74+ return ls75+}76+77+// sync hands the session the room's changes.78+func (ls *liveSession) sync() {79+ for range 10 {80+ select {81+ case <-ls.m.share.link.kick:82+ ls.send(roomMsg{})83+ default:84+ return85+ }86+ }87+}88+89+// suggest has the agent write entries, pairs of a reference and an90+// input, as write_cells does.91+func (ls *liveSession) suggest(message string, entries ...string) mcp.Made {92+ ls.t.Helper()93+ var es []headless.Entry94+ for i := 0; i+1 < len(entries); i += 2 {95+ es = append(es, headless.Entry{Ref: entries[i], Input: entries[i+1]})96+ }97+ made, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Message: message, Fn: func(w *sheet.Workbook) error {98+ _, err := headless.Set(w, es, headless.SetOptions{})99+ return err100+ }})101+ if err != nil {102+ ls.t.Fatal(err)103+ }104+ ls.sync()105+ return made106+}107+108+func TestListenSharesTheWorkbookAsItIs(t *testing.T) {109+ ls := startLive(t)110+ ls.press("<down>", "<down>", "<down>", "7", "<enter>")111+ if !ls.m.book().CanUndo() {112+ t.Fatal("the person can't undo their step")113+ }114+ if !ls.m.changed || ls.m.filename == "" {115+ t.Fatal("the session lost its file")116+ }117+ var peers string118+ _, peers = ls.m.Others()119+ if peers != "claude" {120+ t.Fatalf("others: %q", peers)121+ }122+ if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◆ claude") {123+ t.Errorf("status line %q", got)124+ }125+ eps, _ := cowork.Endpoints(filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "012"))126+ if len(eps) != 1 || eps[0].Workbook != "budget" {127+ t.Fatalf("endpoints %+v", eps)128+ }129+}130+131+func TestSuggestionMarkedAndAccepted(t *testing.T) {132+ ls := startLive(t)133+ made := ls.suggest("keep the total at 50", "A1", "48")134+ if made.Suggestion != 1 || made.Applied {135+ t.Fatalf("made %+v", made)136+ }137+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {138+ t.Fatalf("the suggestion was made: A3 = %v", got)139+ }140+ if !strings.Contains(ls.screen(), "◇40") && !strings.Contains(ls.screen(), "◇ ") {141+ t.Errorf("no mark on A1:\n%s", ls.screen())142+ }143+ ls.press("<down>", "<up>")144+ ctx := line(ls.m, contextLine)145+ if !strings.Contains(ctx, "◆ claude") || !strings.Contains(ctx, "suggests 48, now 40: keep the total at 50") {146+ t.Errorf("context line %q", ctx)147+ }148+ if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◇ 1 suggestion") {149+ t.Errorf("status line %q", got)150+ }151+ ls.press("<ctrl+alt+a>")152+ if !strings.Contains(ls.screen(), "Suggestions") || !strings.Contains(ls.screen(), "A1 40 → 48") {153+ t.Fatalf("the panel:\n%s", ls.screen())154+ }155+ ls.press("<enter>")156+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 50 {157+ t.Fatalf("A3 = %v after accepting", got)158+ }159+ if ls.m.overlay != nil {160+ t.Error("the panel stayed open with nothing waiting")161+ }162+ if !strings.Contains(ls.m.note, "Accepted all of claude's suggestion") {163+ t.Errorf("note %q", ls.m.note)164+ }165+ if ls.m.book().CanUndo() {166+ t.Error("the person's undo takes back the agent's step")167+ }168+ if label, err := ls.agent.Undo(context.Background()); err != nil || label != "set" {169+ t.Fatalf("the agent's undo: %q %v", label, err)170+ }171+ ls.sync()172+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {173+ t.Fatalf("A3 = %v after the agent's undo", got)174+ }175+}176+177+func TestSuggestionByCellInThePanel(t *testing.T) {178+ ls := startLive(t)179+ ls.suggest("", "A1", "1", "A2", "2000")180+ ls.press("<ctrl+alt+a>", "<down>", "<down>", "r")181+ if got := input(ls.m, "A2"); got != "2" {182+ t.Fatalf("A2 = %q after rejecting it", got)183+ }184+ ls.press("<enter>") // the heading again: the rest185+ if got := input(ls.m, "A1"); got != "1" {186+ t.Fatalf("A1 = %q after accepting the rest", got)187+ }188+ list, _ := ls.agent.Suggestions(context.Background())189+ if list[0].State != "accepted in part" {190+ t.Fatalf("suggestions %+v", list)191+ }192+}193+194+func TestSuggestionRejectedTellsTheAgent(t *testing.T) {195+ ls := startLive(t)196+ ls.suggest("", "B1", "x")197+ ls.press("<ctrl+alt+a>", "r")198+ made := ls.suggest("", "B2", "y")199+ if len(made.Notices) != 1 || !strings.Contains(made.Notices[0], "was rejected") {200+ t.Fatalf("notices %v", made.Notices)201+ }202+}203+204+func TestInviteChoosesTheScope(t *testing.T) {205+ ls := startLive(t)206+ ls.m.runCommand("agent.invite")207+ ls.press("Read", "<enter>")208+ ls.sync()209+ if !strings.Contains(ls.m.note, "Agents may change read only") {210+ t.Errorf("note %q", ls.m.note)211+ }212+ _, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Fn: func(w *sheet.Workbook) error {213+ return w.Sheet(0).Set(sheet.Addr{}, "1")214+ }})215+ if err == nil || !strings.Contains(err.Error(), "read only") {216+ t.Fatalf("a read-only agent wrote: %v", err)217+ }218+}219+220+func TestDirectEditsSetting(t *testing.T) {221+ ls := startLive(t)222+ ls.m.runCommand("agent.direct")223+ ls.sync()224+ made := ls.suggest("", "A1", "8")225+ if !made.Applied || input(ls.m, "A1") != "8" {226+ t.Fatalf("made %+v, A1 %q", made, input(ls.m, "A1"))227+ }228+}229+230+// ask waits for the agent's question to reach the session and shows it.231+func (ls *liveSession) waitAsk() {232+ ls.t.Helper()233+ for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {234+ ls.sync()235+ ls.send(nil)236+ if ls.m.agents.asking != nil {237+ return238+ }239+ }240+ ls.t.Fatal("the question didn't come")241+}242+243+func TestAgentAsksOnTheContextLine(t *testing.T) {244+ ls := startLive(t)245+ got := make(chan *sdk.ElicitResult, 1)246+ go func() {247+ res, err := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Overwrite B2:B40?"})248+ if err != nil {249+ t.Error(err)250+ }251+ got <- res252+ }()253+ ls.waitAsk()254+ if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "◆ claude asks: Overwrite B2:B40?") || !strings.Contains(ctx, "Yes") {255+ t.Fatalf("context line %q", ctx)256+ }257+ ls.press("n")258+ if res := <-got; res.Action != "decline" {259+ t.Fatalf("answer %+v", res)260+ }261+}262+263+func TestAgentAsksForValues(t *testing.T) {264+ ls := startLive(t)265+ got := make(chan *sdk.ElicitResult, 1)266+ go func() {267+ res, _ := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Which quarter?", RequestedSchema: map[string]any{268+ "type": "object", "required": []string{"quarter", "target"},269+ "properties": map[string]any{270+ "quarter": map[string]any{"type": "string", "enum": []string{"Q1", "Q2", "Q3"}},271+ "target": map[string]any{"type": "number", "title": "Target"}}}})272+ got <- res273+ }()274+ ls.waitAsk()275+ ls.press("3")276+ if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "Target:") {277+ t.Fatalf("context line %q", ctx)278+ }279+ ls.press("lots", "<enter>")280+ if !strings.Contains(ls.m.errMsg+ls.m.warn, "takes a number") {281+ t.Errorf("no complaint about lots: %q %q", ls.m.errMsg, ls.m.warn)282+ }283+ ls.press("1200", "<enter>")284+ res := <-got285+ if res.Action != "accept" || res.Content["quarter"] != "Q3" || res.Content["target"] != 1200.0 {286+ t.Fatalf("answer %+v", res)287+ }288+}
M internal/ui/file.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/hosts.go +2 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/menubar.go +1 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/model.go +3 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panel.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/pipe_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/prefs.go +2 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/review/review.go +354 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/review/review_test.go +122 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/rooms.go +1 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/share.go +22 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sharecmds.go +9 -3

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sharedraw.go +8 -4

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sharemono_test.go +23 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/scheme.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/theme.go +7 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/view.go +3 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/doclint/main.go +3 -1

Not shown: this page already shows 6000 lines of diff.