Resolved against 012 serve and vim keys: serve reads its serve-* options and the JEV key through the config (flags still win), its host key lives in config.Dir(), and vim keys are the keymap option, saved from File > Settings > Vim keys.
- commit
- 5c71a188f5386df5b0c8fb8a3062ff06f4209483
- parent
- 0e752a3e80fdac6ed211963e21ea35e429f97903
- parent
- 1429607a4a37a3b6429d93ee60906a4eb42633a0
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,30 @@
1+012 includes third-party material, used under the licenses below.2+3+-------------------------------------------------------------------------------4+internal/ui/theme/themes.json5+6+The built-in terminal color schemes, copied unchanged from VHS7+(https://github.com/charmbracelet/vhs, themes.json, v0.12.1). Each scheme's8+original authors are credited in its "meta.credits" entry.9+10+MIT License11+12+Copyright (c) 2022-2023 Charmbracelet, Inc13+14+Permission is hereby granted, free of charge, to any person obtaining a copy15+of this software and associated documentation files (the "Software"), to deal16+in the Software without restriction, including without limitation the rights17+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell18+copies of the Software, and to permit persons to whom the Software is19+furnished to do so, subject to the following conditions:20+21+The above copyright notice and this permission notice shall be included in all22+copies or substantial portions of the Software.23+24+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR25+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,26+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE27+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER28+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,29+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE30+SOFTWARE.@@ -27,7 +27,7 @@ Requires Go 1.27. Or from a clone: `make build` puts the binary in `bin/012`.
2727 ```2828 2929 F1 shows every shortcut, F10 or Alt+letter opens the menus, and Ctrl+K30-searches every command.30+searches every command. `012 config edit` opens the settings file.3131 3232 ## What it does3333 @@ -58,6 +58,11 @@ searches every command.
5858 menus and a command palette styled like terminal software, not a GUI,5959 and optional vim keys (hjkl, counts, dd/yy/p, visual selection, `:`6060 commands) in File > Settings.61+- **Themes and a config file.** Your terminal's colors by default, or any62+ of 349 color schemes (or your own Ghostty theme) with solid menu and63+ status bars; one Ghostty-style config file for every setting, and the64+ JEV API key kept in the OS keychain ([config](docs/config.md),65+ [themes](docs/themes.md)).6166 6267 ## Demos6368 @@ -83,6 +88,7 @@ kitty, Ghostty and WezTerm they are real images.
8388 - [Files](docs/files.md)8489 - [JEV functions](docs/jev.md)8590 - [Serving over SSH](docs/ssh.md): `012 serve`, public-key only, confined to one directory91+- [Configuration](docs/config.md) and [themes](docs/themes.md)8692 - [Architecture](docs/architecture.md), [UX bar](docs/UX.md) and [testing](docs/testing.md)8793 - [Limits](docs/limits.md): how big a sheet 012 handles and where it slows down8894 - [Observability](docs/observability.md): event logs, DuckDB, and a local Collector, ClickHouse and Grafana stack@@ -0,0 +1,180 @@
1+package main2+3+import (4+ "bufio"5+ "context"6+ "errors"7+ "fmt"8+ "os"9+ "strings"10+ "time"11+12+ "github.com/charmbracelet/x/term"13+14+ "github.com/FelineStateMachine/012/internal/config"15+ "github.com/FelineStateMachine/012/internal/jev"16+ "github.com/FelineStateMachine/012/internal/keyring"17+ "github.com/FelineStateMachine/012/internal/ui/theme"18+)19+20+// runConfig is `012 config ...`: see docs/config.md.21+func runConfig(args []string, e env) error {22+ cfg, err := loadConfig(e, nil)23+ if err != nil {24+ return err25+ }26+ sub := ""27+ if len(args) > 0 {28+ sub = args[0]29+ }30+ if len(args) > 1 {31+ return usage()32+ }33+ switch sub {34+ case "":35+ fmt.Fprint(e.stdout, cfg.Show())36+ fmt.Fprintf(e.stdout, "\n# JEV API key: %s\n", keyStatus(cfg, e))37+ case "path":38+ fmt.Fprintln(e.stdout, cfg.Path)39+ case "default":40+ fmt.Fprint(e.stdout, config.DefaultFile())41+ case "edit":42+ return editConfig(cfg.Path, e)43+ case "themes":44+ listThemes(cfg, e)45+ case "set-key":46+ return setKey(cfg, e)47+ case "delete-key":48+ return deleteKey(cfg, e)49+ default:50+ return usage()51+ }52+ return nil53+}54+55+// keyStatus says where the API key would come from, without showing it56+// or running jev-api-key-command.57+func keyStatus(c *config.Config, e env) string {58+ if strings.TrimSpace(e.getenv(jev.KeyEnv)) != "" {59+ return "from $" + jev.KeyEnv60+ }61+ if store := keyStore(c, e); store != nil {62+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)63+ defer cancel()64+ _, err := store.Get(ctx)65+ switch {66+ case err == nil:67+ return "stored in the " + store.Name()68+ case !errors.Is(err, keyring.ErrNotFound):69+ return "the " + store.Name() + " couldn't be read: " + err.Error()70+ }71+ }72+ if c.String("jev-api-key-command") != "" {73+ return "from jev-api-key-command (not run here)"74+ }75+ return "not set; run 012 config set-key"76+}77+78+func editConfig(path string, e env) error {79+ if err := config.EnsureFile(path); err != nil {80+ return err81+ }82+ cmd, err := config.Editor(path, e.getenv)83+ if err != nil {84+ return err85+ }86+ if e.editorIO {87+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr88+ }89+ return cmd.Run()90+}91+92+func listThemes(c *config.Config, e env) {93+ current := c.Theme()94+ for _, t := range theme.List(config.ThemesDir()) {95+ kind := "light"96+ if t.Dark {97+ kind = "dark"98+ }99+ switch {100+ case t.Name == theme.Terminal:101+ kind = "the terminal's own colors"102+ case t.User:103+ kind += ", " + config.ThemesDir()104+ }105+ mark := " "106+ if strings.EqualFold(t.Name, current.Light) || strings.EqualFold(t.Name, current.Dark) {107+ mark = "*"108+ }109+ fmt.Fprintf(e.stdout, "%s %-36s %s\n", mark, t.Name, kind)110+ }111+}112+113+// setKey stores the API key read from the terminal, without echo, or114+// from stdin when it's a pipe. The key is never an argument.115+func setKey(c *config.Config, e env) error {116+ store := keyStore(c, e)117+ if store == nil {118+ return errors.New("jev-credential-store is off in the config; use jev-api-key-command or TYPESAFE_API_KEY instead")119+ }120+ var key string121+ var err error122+ if e.isTTY {123+ key, err = e.readKey("TypeSafe API key (not shown): ")124+ } else {125+ key, err = firstLine(e.stdin)126+ }127+ if err != nil {128+ return err129+ }130+ if key = strings.TrimSpace(key); key == "" {131+ return errors.New("no key given; nothing changed")132+ }133+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)134+ defer cancel()135+ if err := store.Set(ctx, key); err != nil {136+ return err137+ }138+ fmt.Fprintf(e.stdout, "Saved the JEV API key in the %s.\n", store.Name())139+ if strings.TrimSpace(e.getenv(jev.KeyEnv)) != "" {140+ fmt.Fprintf(e.stdout, "$%s is set too, and wins while it is.\n", jev.KeyEnv)141+ }142+ return nil143+}144+145+func deleteKey(c *config.Config, e env) error {146+ store := keyStore(c, e)147+ if store == nil {148+ return errors.New("jev-credential-store is off in the config")149+ }150+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)151+ defer cancel()152+ switch err := store.Delete(ctx); {153+ case errors.Is(err, keyring.ErrNotFound):154+ fmt.Fprintf(e.stdout, "No JEV API key in the %s.\n", store.Name())155+ case err != nil:156+ return err157+ default:158+ fmt.Fprintf(e.stdout, "Deleted the JEV API key from the %s.\n", store.Name())159+ }160+ return nil161+}162+163+func firstLine(r interface{ Read([]byte) (int, error) }) (string, error) {164+ sc := bufio.NewScanner(r)165+ if sc.Scan() {166+ return sc.Text(), nil167+ }168+ return "", sc.Err()169+}170+171+func isTerminal(f *os.File) bool { return term.IsTerminal(f.Fd()) }172+173+// readPassword prompts on stderr and reads a line from the terminal with174+// echo off.175+func readPassword(prompt string) (string, error) {176+ fmt.Fprint(os.Stderr, prompt)177+ b, err := term.ReadPassword(os.Stdin.Fd())178+ fmt.Fprintln(os.Stderr)179+ return string(b), err180+}@@ -2,59 +2,175 @@
22 package main33 44 import (5+ "context"56 "errors"67 "fmt"8+ "io"79 "io/fs"810 "os"9- "path/filepath"1011 1112 tea "charm.land/bubbletea/v2"1213 14+ "github.com/FelineStateMachine/012/internal/config"1315 "github.com/FelineStateMachine/012/internal/fileio"1416 "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"1518 "github.com/FelineStateMachine/012/internal/sheet"1619 "github.com/FelineStateMachine/012/internal/ui"1720 )1821 1922 func main() {20- if err := run(os.Args[1:]); err != nil {23+ if err := run(os.Args[1:], system()); err != nil {2124 fmt.Fprintln(os.Stderr, "012:", err)2225 os.Exit(1)2326 }2427 }2528 26-func run(args []string) error {29+// env is what 012 takes from the process, so tests can substitute a30+// temporary config and a fake credential store.31+type env struct {32+ getenv func(string) string33+ keys keyring.Store // the OS credential store34+ stdin io.Reader35+ stdout io.Writer36+ stderr io.Writer37+ readKey func(prompt string) (string, error) // reads a secret from the terminal without echo38+ isTTY bool // stdin is a terminal39+ runTUI func(m tea.Model) error40+ editorIO bool // run the editor attached to the terminal41+}42+43+func system() env {44+ return env{45+ getenv: os.Getenv, keys: keyring.System(),46+ stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr,47+ readKey: readPassword, isTTY: isTerminal(os.Stdin), editorIO: true,48+ runTUI: func(m tea.Model) error { _, err := tea.NewProgram(m).Run(); return err },49+ }50+}51+52+func usage() error {53+ return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +54+ " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +55+ " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/ssh.md)\n" +56+ " 012 config [path|edit|default|themes|set-key|delete-key]")57+}58+59+func run(args []string, e env) error {60+ if len(args) > 0 && args[0] == "config" {61+ return runConfig(args[1:], e)62+ }2763 if len(args) > 0 && args[0] == "serve" {2864 // A file called serve opens as ./serve.29- return runServe(args[1:])65+ return runServe(args[1:], e)3066 }31- tc, args, err := telemetryFlags(args)67+ flags, args, err := config.ParseFlags(args)3268 if err != nil || len(args) > 1 {33- return errors.New("usage: 012 [--log file.jsonl] [--otlp http://localhost:4318] [file]: a " + sheet.FileExt + " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import")69+ return usage()70+ }71+ cfg, err := loadConfig(e, flags)72+ if err != nil {73+ return err3474 }35- stopTelemetry, err := startTelemetry(tc)75+ stopTelemetry, err := startTelemetry(telemetryConfig(cfg))3676 if err != nil {3777 return err3878 }3979 defer stopTelemetry()40- // JEV functions run when an API key is set, in the environment or a41- // .env file next to the sheet or in the current directory. EnableJEV42- // gives the workbook the cache, which has its JEV cells queue their43- // questions.44- var jevClient jev.Client45- var jevCache *jev.Cache46- dirs := []string{"."}47- if len(args) == 1 {48- dirs = append(dirs, filepath.Dir(args[0]))80+81+ m, err := openModel(args)82+ if err != nil {83+ return err4984 }50- if cfg, ok := jev.LoadConfig(dirs...); ok {51- c, err := jev.NewClient(cfg)52- if err != nil {53- return fmt.Errorf("JEV: %w", err)85+ settings := ui.Settings{86+ Config: cfg,87+ Reload: func() *config.Config { c, _ := loadConfig(e, flags); return c },88+ ThemesDir: config.ThemesDir(),89+ Keys: keyStore(cfg, e),90+ Connect: func(key string) (jev.Client, error) { return jev.NewClient(jevConfig(cfg, key)) },91+ Notes: configNotes(cfg),92+ }93+ client, notes := startJEV(cfg, e, settings.Keys)94+ settings.Notes = append(settings.Notes, notes...)95+ if client != nil {96+ m.EnableJEV(client, jev.NewCache())97+ }98+ m.Configure(settings)99+ return e.runTUI(m)100+}101+102+// loadConfig reads the config file with the environment and flags.103+func loadConfig(e env, flags map[string]string) (*config.Config, error) {104+ path, err := config.DefaultPath()105+ if err != nil {106+ return nil, fmt.Errorf("finding the config directory: %w", err)107+ }108+ return config.Load(path, e.getenv, flags), nil109+}110+111+// configNotes summarizes the config's warnings for the context line.112+func configNotes(c *config.Config) []string {113+ switch n := len(c.Warnings); n {114+ case 0:115+ return nil116+ case 1:117+ return []string{"Config: " + c.Warnings[0].Short()}118+ default:119+ return []string{fmt.Sprintf("Config: %s (and %d more; run 012 config)", c.Warnings[0].Short(), n-1)}120+ }121+}122+123+// keyStore is the credential store, unless the config turns it off.124+func keyStore(c *config.Config, e env) keyring.Store {125+ if !c.Bool("jev-credential-store") {126+ return nil127+ }128+ return e.keys129+}130+131+func jevConfig(c *config.Config, key string) jev.Config {132+ return jev.Config{APIKey: key, BaseURL: c.String("jev-base-url"), Model: c.String("jev-model")}133+}134+135+// startJEV finds the API key and connects, returning notes for the136+// context line: why the key couldn't be read, or that a .env file's key137+// is no longer read.138+//139+// jev-api-key-command runs only when the environment and the credential140+// store have no key, and then only when a sheet first asks JEV141+// something, so a password manager doesn't prompt on every start.142+func startJEV(c *config.Config, e env, store keyring.Store) (jev.Client, []string) {143+ ctx, cancel := context.WithTimeout(context.Background(), jev.KeyCommandTimeout)144+ defer cancel()145+ key, _, err := jev.ResolveKey(ctx, jev.KeySources{Getenv: e.getenv, Store: store})146+ var notes []string147+ if err != nil {148+ notes = append(notes, "JEV API key: "+err.Error())149+ }150+ if command := c.String("jev-api-key-command"); key == "" && command != "" {151+ return jev.Lazy(func() (jev.Client, error) {152+ key, _, err := jev.ResolveKey(context.Background(), jev.KeySources{Command: command})153+ if err != nil {154+ return nil, err155+ }156+ return jev.NewClient(jevConfig(c, key))157+ }), notes158+ }159+ if key == "" {160+ if jev.DotEnvHasKey(".") {161+ notes = append(notes, "012 no longer reads TYPESAFE_API_KEY from .env files: run 012 config set-key to store it")54162 }55- jevClient, jevCache = c, jev.NewCache()163+ return nil, notes56164 }165+ client, err := jev.NewClient(jevConfig(c, key))166+ if err != nil {167+ return nil, append(notes, "JEV: "+err.Error())168+ }169+ return client, notes170+}57171 172+// openModel opens the sheet named in args, or starts an import of it.173+func openModel(args []string) (*ui.Model, error) {58174 s, name := sheet.New(), ""59175 var importName string60176 if len(args) == 1 {@@ -62,7 +178,7 @@ func run(args []string) error {
62178 // Other formats are imported in the background once the63179 // screen is up, with progress; the file must exist.64180 if _, err := os.Stat(args[0]); err != nil {65- return err181+ return nil, err66182 }67183 importName, args = args[0], nil68184 }@@ -74,11 +190,11 @@ func run(args []string) error {
74190 case errors.Is(err, fs.ErrNotExist):75191 // Start a new worksheet that will be saved under this name.76192 case err != nil:77- return err193+ return nil, err78194 default:79195 defer f.Close()80196 if s, err = sheet.Read(f); err != nil {81- return fmt.Errorf("%s: %w", name, err)197+ return nil, fmt.Errorf("%s: %w", name, err)82198 }83199 }84200 }@@ -86,9 +202,5 @@ func run(args []string) error {
86202 if importName != "" {87203 m.Import(importName)88204 }89- if jevClient != nil {90- m.EnableJEV(jevClient, jevCache)91- }92- _, err = tea.NewProgram(m).Run()93- return err205+ return m, nil94206 }@@ -0,0 +1,176 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/keyring"15+)16+17+// testEnv points the config at a temporary directory and the credential18+// store at memory, so tests never touch the user's config or keychain.19+func testEnv(t *testing.T, vars map[string]string) (env, *bytes.Buffer, *keyring.Memory) {20+ t.Helper()21+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())22+ out := &bytes.Buffer{}23+ store := &keyring.Memory{}24+ return env{25+ getenv: func(k string) string { return vars[k] },26+ keys: store, stdin: strings.NewReader(""), stdout: out, stderr: out,27+ readKey: func(string) (string, error) { t.Fatal("read from a terminal"); return "", nil },28+ runTUI: func(tea.Model) error { return nil },29+ }, out, store30+}31+32+func writeConfig(t *testing.T, text string) string {33+ t.Helper()34+ path, _ := config.DefaultPath()35+ os.MkdirAll(filepath.Dir(path), 0o700)36+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {37+ t.Fatal(err)38+ }39+ return path40+}41+42+func TestConfigCommands(t *testing.T) {43+ e, out, _ := testEnv(t, map[string]string{"O12_THEME": "Dracula"})44+ path := writeConfig(t, "theme = Nord\nlog-level = warn\nwhat = 1\n")45+ if err := run([]string{"config", "path"}, e); err != nil || strings.TrimSpace(out.String()) != path {46+ t.Errorf("path: %q %v", out, err)47+ }48+ out.Reset()49+ if err := run([]string{"config"}, e); err != nil {50+ t.Fatal(err)51+ }52+ for _, want := range []string{"theme = Dracula", "# $O12_THEME", "log-level = warn", path + ":2", "chart-images = true", "# default",53+ `warning: ` + path + `:3: unknown key "what"`, "# JEV API key: not set; run 012 config set-key"} {54+ if !strings.Contains(out.String(), want) {55+ t.Errorf("012 config lacks %q:\n%s", want, out)56+ }57+ }58+ out.Reset()59+ if err := run([]string{"config", "default"}, e); err != nil || !strings.Contains(out.String(), "# theme = terminal") {60+ t.Errorf("default: %v\n%s", err, out)61+ }62+ out.Reset()63+ if err := run([]string{"config", "themes"}, e); err != nil || !strings.Contains(out.String(), "* Dracula") || !strings.Contains(out.String(), " terminal") {64+ t.Errorf("themes: %v\n%s", err, out)65+ }66+ if err := run([]string{"config", "bogus"}, e); err == nil {67+ t.Error("unknown subcommand")68+ }69+}70+71+func TestSetAndDeleteKey(t *testing.T) {72+ const key = "ts-live-abc123"73+ e, out, store := testEnv(t, nil)74+ e.stdin = strings.NewReader(key + "\n")75+ if err := run([]string{"config", "set-key"}, e); err != nil {76+ t.Fatal(err)77+ }78+ if got, _ := store.Get(context.Background()); got != key {79+ t.Errorf("stored %q", got)80+ }81+ if strings.Contains(out.String(), key) {82+ t.Errorf("the key was printed: %s", out)83+ }84+ out.Reset()85+ run([]string{"config"}, e)86+ if !strings.Contains(out.String(), "JEV API key: stored in the test store") || strings.Contains(out.String(), key) {87+ t.Errorf("status: %s", out)88+ }89+ out.Reset()90+ if err := run([]string{"config", "delete-key"}, e); err != nil || !strings.Contains(out.String(), "Deleted") {91+ t.Errorf("delete: %v %s", err, out)92+ }93+ e.stdin = strings.NewReader("")94+ if err := run([]string{"config", "set-key"}, e); err == nil {95+ t.Error("empty key accepted")96+ }97+98+ // A terminal reads the key without echo.99+ e.isTTY = true100+ e.readKey = func(string) (string, error) { return "from-tty", nil }101+ if err := run([]string{"config", "set-key"}, e); err != nil {102+ t.Fatal(err)103+ }104+ if got, _ := store.Get(context.Background()); got != "from-tty" {105+ t.Errorf("stored %q", got)106+ }107+108+ writeConfig(t, "jev-credential-store = false\n")109+ if err := run([]string{"config", "set-key"}, e); err == nil || !strings.Contains(err.Error(), "jev-credential-store is off") {110+ t.Errorf("store off: %v", err)111+ }112+}113+114+func TestStartJEV(t *testing.T) {115+ e, _, store := testEnv(t, nil)116+ c, _ := loadConfig(e, nil)117+ if client, notes := startJEV(c, e, store); client != nil || len(notes) != 0 {118+ t.Errorf("no key: %v %v", client, notes)119+ }120+ store.Set(context.Background(), "k")121+ if client, notes := startJEV(c, e, store); client == nil || len(notes) != 0 {122+ t.Errorf("stored key: %v %v", client, notes)123+ }124+125+ // A .env with a key is never read, but noticed.126+ store.Delete(context.Background())127+ dir := t.TempDir()128+ t.Chdir(dir)129+ os.WriteFile(".env", []byte("TYPESAFE_API_KEY=should-not-be-read\nTYPESAFE_BASE_URL=http://evil.example\n"), 0o600)130+ client, notes := startJEV(c, e, store)131+ if client != nil || len(notes) != 1 || !strings.Contains(notes[0], "012 config set-key") || strings.Contains(notes[0], "should-not") {132+ t.Errorf(".env: %v %v", client, notes)133+ }134+135+ // jev-api-key-command waits for the first question.136+ writeConfig(t, "jev-api-key-command = /nonexistent/key-helper\n")137+ c, _ = loadConfig(e, nil)138+ if client, notes := startJEV(c, e, store); client == nil || len(notes) != 0 {139+ t.Errorf("command: %v %v", client, notes)140+ }141+142+ // The base URL only comes from the config or environment, and must143+ // be https.144+ e.getenv = func(k string) string {145+ return map[string]string{"TYPESAFE_API_KEY": "k", "TYPESAFE_BASE_URL": "http://evil.example"}[k]146+ }147+ c, _ = loadConfig(e, nil)148+ if c.String("jev-base-url") != "" || len(c.Warnings) == 0 {149+ t.Errorf("http base URL accepted: %q", c.String("jev-base-url"))150+ }151+}152+153+func TestTelemetryConfig(t *testing.T) {154+ e, _, _ := testEnv(t, map[string]string{"O12_LOG": "env.jsonl", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://env:4318"})155+ writeConfig(t, "log-file = file.jsonl\nlog-level = debug\n")156+ flags, rest, err := config.ParseFlags([]string{"--log", "flag.jsonl", "b.csv"})157+ if err != nil || len(rest) != 1 {158+ t.Fatal(rest, err)159+ }160+ c, _ := loadConfig(e, flags)161+ tc := telemetryConfig(c)162+ if tc.LogPath != "flag.jsonl" || tc.OTLP.Endpoint != "http://env:4318" || tc.Level.String() != "DEBUG" {163+ t.Errorf("got %+v", tc)164+ }165+}166+167+func TestRunWarnsOnConfig(t *testing.T) {168+ e, _, _ := testEnv(t, nil)169+ writeConfig(t, "bogus = 1\ntheme = No Such Theme\n")170+ if err := run(nil, e); err != nil {171+ t.Fatal(err)172+ }173+ if err := run([]string{"a", "b"}, e); err == nil {174+ t.Error("two files")175+ }176+}@@ -15,16 +15,29 @@ import (
1515 1616 gossh "golang.org/x/crypto/ssh"1717 18- "github.com/FelineStateMachine/012/internal/jev"18+ "github.com/FelineStateMachine/012/internal/config"1919 "github.com/FelineStateMachine/012/internal/serve"2020 )2121 2222 const serveUsage = "usage: 012 serve [--listen addr] [--authorized-keys file] [--host-key file] [--idle-timeout 30m] [--max-sessions 8] [dir]"2323 24-// serveFlags parses 012 serve's arguments into options, starting from25-// the defaults. The served directory may come before or after the flags.26-func serveFlags(args []string, stderr io.Writer) (serve.Options, error) {24+// serveOptions are 012 serve's settings from the config file, the25+// serve-* options, over the built-in defaults.26+func serveOptions(c *config.Config) serve.Options {2727 o := serve.Defaults()28+ o.Listen = c.String("serve-listen")29+ o.AuthorizedKeys = c.String("serve-authorized-keys")30+ if k := c.String("serve-host-key"); k != "" {31+ o.HostKey = k32+ }33+ o.IdleTimeout = c.Duration("serve-idle-timeout")34+ o.MaxSessions = c.Int("serve-max-sessions")35+ return o36+}37+38+// serveFlags parses 012 serve's arguments into options, starting from o.39+// The served directory may come before or after the flags.40+func serveFlags(args []string, stderr io.Writer, o serve.Options) (serve.Options, error) {2841 fs := flag.NewFlagSet("012 serve", flag.ContinueOnError)2942 fs.SetOutput(stderr)3043 fs.Usage = func() {@@ -57,32 +70,40 @@ func serveFlags(args []string, stderr io.Writer) (serve.Options, error) {
5770 }5871 5972 // runServe is 012 serve: sessions over SSH until interrupted.60-func runServe(args []string) error {61- tc, args, err := telemetryFlags(args)73+func runServe(args []string, e env) error {74+ flags, args, err := config.ParseFlags(args)75+ if err != nil {76+ return err77+ }78+ cfg, err := loadConfig(e, flags)6279 if err != nil {6380 return err6481 }65- o, err := serveFlags(args, os.Stderr)82+ for _, w := range cfg.Warnings {83+ fmt.Fprintln(e.stderr, "config: "+w.Short())84+ }85+ o, err := serveFlags(args, e.stderr, serveOptions(cfg))6686 if errors.Is(err, flag.ErrHelp) {6787 return nil6888 }6989 if err != nil {7090 return err7191 }72- stopTelemetry, err := startTelemetry(tc)92+ stopTelemetry, err := startTelemetry(telemetryConfig(cfg))7393 if err != nil {7494 return err7595 }7696 defer stopTelemetry()77- log := slog.New(slog.NewTextHandler(os.Stderr, nil))97+ log := slog.New(slog.NewTextHandler(e.stderr, nil))7898 d := serve.Deps{Log: log}79- // JEV functions run when the server's own environment has an API80- // key; sessions never read .env files.81- if cfg, ok := jev.LoadConfig(); ok {82- if d.JEV, err = jev.NewClient(cfg); err != nil {83- return fmt.Errorf("JEV: %w", err)84- }99+ // JEV functions run when the server has an API key: from its own100+ // environment, the credential store or jev-api-key-command. Sessions101+ // never read .env files.102+ client, notes := startJEV(cfg, e, keyStore(cfg, e))103+ for _, n := range notes {104+ fmt.Fprintln(e.stderr, n)85105 }106+ d.JEV = client86107 srv, err := serve.New(o, d)87108 if err != nil {88109 return err@@ -96,7 +117,7 @@ func runServe(args []string) error {
96117 log.Warn("listening beyond this machine: anyone holding an authorized key who can reach " + o.Listen + " can read and write " + srv.Dir())97118 }98119 }99- fmt.Fprintf(os.Stderr, "012 serving %s on %s\nhost key %s\nconnect: ssh -p %s %s\n",120+ fmt.Fprintf(e.stderr, "012 serving %s on %s\nhost key %s\nconnect: ssh -p %s %s\n",100121 srv.Dir(), l.Addr(), gossh.FingerprintSHA256(srv.HostKey()), portOf(l.Addr()), hostOf(l.Addr()))101122 102123 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)@@ -9,18 +9,18 @@ import (
99 )1010 1111 func TestServeFlags(t *testing.T) {12- o, err := serveFlags(nil, io.Discard)12+ o, err := serveFlags(nil, io.Discard, serve.Defaults())1313 if err != nil || o != serve.Defaults() {1414 t.Errorf("no flags: %+v, %v; want the defaults", o, err)1515 }1616 o, err = serveFlags([]string{"--listen", "127.0.0.1:9000", "sheets", "-idle-timeout=5m", "--max-sessions", "3",17- "--authorized-keys", "/k", "--host-key=/h"}, io.Discard)17+ "--authorized-keys", "/k", "--host-key=/h"}, io.Discard, serve.Defaults())1818 want := serve.Options{Dir: "sheets", Listen: "127.0.0.1:9000", AuthorizedKeys: "/k", HostKey: "/h", IdleTimeout: 5 * time.Minute, MaxSessions: 3}1919 if err != nil || o != want {2020 t.Errorf("got %+v, %v; want %+v", o, err, want)2121 }2222 for _, args := range [][]string{{"a", "b"}, {"--nope"}, {"--max-sessions", "x"}} {23- if _, err := serveFlags(args, io.Discard); err == nil {23+ if _, err := serveFlags(args, io.Discard, serve.Defaults()); err == nil {2424 t.Errorf("%q: no error", args)2525 }2626 }@@ -1,40 +1,40 @@
11 package main22 33 import (4- "errors"54 "log/slog"5+ "os"66 "runtime/debug"7- "strings"87 8+ "github.com/FelineStateMachine/012/internal/config"99 "github.com/FelineStateMachine/012/internal/sheet"1010 "github.com/FelineStateMachine/012/internal/telemetry"1111 )1212 13-// telemetryFlags takes --log path and --otlp url (or --log=path,14-// --otlp=url) out of args. Without them the log file comes from O12_LOG15-// and the OTLP endpoint from OTEL_EXPORTER_OTLP_ENDPOINT (and the other16-// OTEL_* variables), and telemetry stays off when none is set.13+// telemetryConfig is where telemetry goes: the log file, level and OTLP14+// endpoint from the config (whose flags and variables win over the15+// file), and the rest of OTLP's settings from the OTEL_* variables.16+// Telemetry stays off when neither a log file nor an endpoint is set.17+func telemetryConfig(c *config.Config) telemetry.Config {18+ tc := telemetry.ConfigFromEnv()19+ tc.LogPath = c.String("log-file")20+ tc.OTLP.Endpoint = c.String("otlp-endpoint")21+ tc.Level.UnmarshalText([]byte(c.String("log-level")))22+ return tc23+}24+25+// telemetryFlags takes the telemetry flags (--log, --otlp) out of args,26+// and reads the rest of telemetry's settings from the config file and27+// environment. 012 serve uses it before parsing its own flags.1728 func telemetryFlags(args []string) (telemetry.Config, []string, error) {18- c := telemetry.ConfigFromEnv()19- flags := map[string]*string{"--log": &c.LogPath, "--otlp": &c.OTLP.Endpoint}20- var rest []string21- for i := 0; i < len(args); i++ {22- a := args[i]23- name, value, hasValue := strings.Cut(a, "=")24- dst, ok := flags[name]25- switch {26- case !ok:27- rest = append(rest, a)28- case hasValue:29- *dst = value30- case i+1 < len(args):31- *dst = args[i+1]32- i++33- default:34- return c, nil, errors.New(name + " needs a value")35- }29+ flags, rest, err := config.ParseFlags(args)30+ if err != nil {31+ return telemetry.Config{}, nil, err32+ }33+ path, err := config.DefaultPath()34+ if err != nil {35+ return telemetry.Config{}, nil, err3636 }37- return c, rest, nil37+ return telemetryConfig(config.Load(path, os.Getenv, flags)), rest, nil3838 }3939 4040 // startTelemetry opens the log and reports every recalculation to it.@@ -1,22 +0,0 @@
1-package main2-3-import (4- "slices"5- "testing"6-)7-8-func TestTelemetryFlags(t *testing.T) {9- t.Setenv("O12_LOG", "")10- t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://env:4318")11- c, rest, err := telemetryFlags([]string{"--log", "a.jsonl", "--otlp=http://localhost:4318", "b.csv"})12- if err != nil || c.LogPath != "a.jsonl" || c.OTLP.Endpoint != "http://localhost:4318" || !slices.Equal(rest, []string{"b.csv"}) {13- t.Errorf("got %+v %v %v", c, rest, err)14- }15- c, rest, err = telemetryFlags([]string{"b.csv"})16- if err != nil || c.OTLP.Endpoint != "http://env:4318" || len(rest) != 1 {17- t.Errorf("env endpoint: %+v %v %v", c, rest, err)18- }19- if _, _, err := telemetryFlags([]string{"--otlp"}); err == nil {20- t.Error("--otlp without a value")21- }22-}@@ -8,8 +8,10 @@
88 - [Working with data](data.md): selection, copy and fill, undo, freeze, sort, filter, find99 - [Charts, links and the terminal](charts.md): charts, hyperlinks, terminal features used1010 - [Files](files.md): the `.012` format, import and export11-- [JEV functions](jev.md): asking a hosted model from formulas11+- [JEV functions](jev.md): asking a hosted model from formulas, and storing the API key1212 - [Serving over SSH](ssh.md): `012 serve`, setup, the security model and limits13+- [Configuration](config.md): the config file, `012 config` and every option14+- [Themes](themes.md): terminal color schemes, built in or your own1315 1416 **Working on 012**1517 @@ -45,7 +45,12 @@ follow-up task.
4545 `lipgloss.NewStyle()` in views. Need a new role? Add it to `theme` with a4646 comment saying what it's for, in both the dark and light variants.4747 - Use the 16 ANSI colors so the user's terminal palette applies. No48- hard-coded RGB.48+ hard-coded RGB. Color schemes ([themes.md](themes.md)) are data: they49+ map the same roles to their colors and correct contrast, and50+ `TestEveryThemeReadable` must pass for a new role.51+- Bars (menu bar, formula bar, context line, column headers, status line)52+ get their background from their row role and must fill the full width,53+ at odd widths and under overlays; draw them with `theme.Fill`.4954 - Text on a colored background needs about 4.5:1 contrast against both5055 reference palettes (`e2e/palette_test.go`). Check it in the gallery.5156 - Never rely on color alone: state also shows as text (mode indicator,@@ -4,19 +4,21 @@
44 Lip Gloss v2.55 66 ```7-cmd/012 entry point: flags, JEV setup, opening or importing a file, 012 serve7+cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file8+internal/config the config file and the registry of options89 internal/sheet the engine: cells, evaluation, functions, recalculation, undo, files910 internal/formula the formula language: references, lexer, parser, printer, rewriting1011 internal/numfmt number formats, rounding, General, date serials1112 internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk11213 internal/chart chart layout, text rendering and kitty image encoding13-internal/jev JEV configuration, answer cache and TypeSafe client14+internal/jev the API key's resolution, answer cache and TypeSafe client15+internal/keyring the OS credential store the API key lives in1416 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats1517 internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)1618 internal/confine resolving typed file names, confined to a directory when served1719 internal/stress synthetic worst-case sheets for the -tags stress benchmarks1820 internal/ui the Bubble Tea model: modes, menus, overlays, rendering19- theme style roles and the widgets drawn with them (frames, key chips)21+ theme style roles, color schemes and the widgets drawn with them (frames, key chips)2022 rowtext laying out a row of cell text across the columns on screen2123 formula reading the formula being typed (F4, the word and call at the caret)2224 e2e/ end-to-end tests through libghostty (separate module, cgo)@@ -227,6 +229,64 @@ root (the local app) uses them as they are, a served session's root
227229 keeps them inside its directory. Every open, save, import, download and228230 file listing goes through `Model.path` or the root.229231 232+## Configuration and secrets233+234+`internal/config` reads one Ghostty-style file (`key = value`, `#`235+comments, `config-file` includes) from `$XDG_CONFIG_HOME/012` or the OS236+config directory; the same directory holds `themes/` and 012 serve's host237+key. Every option is one entry in `config.Options`: name, type, default,238+environment variables, flag, whether Reload config applies it, and a239+description. Parsing and validation, the flags `cmd/012` accepts, `012240+config`'s listing and default file, and [config.md](config.md)'s241+reference (checked by a test) all come from that table. Values are layered defaults < file < environment < flags, each242+remembering its source; problems are warnings, never fatal. The UI gets a243+`ui.Settings` (the config, a reload function, the credential store, a JEV244+connector) through `Model.Configure`, so tests hand it a temporary config245+and a store in memory.246+247+The JEV API key is never in the config. `jev.ResolveKey` takes it from248+`TYPESAFE_API_KEY`, then the credential store, then249+`jev-api-key-command` (split into words and run without a shell, with a250+timeout, only when a sheet first asks JEV something). The base URL comes251+from the config or environment only and must be https unless it's252+loopback, so a file next to a sheet can't redirect the key.253+254+`internal/keyring` is the credential store, chosen for the least255+dependency weight that is still correct on each OS, with `CGO_ENABLED=0`:256+257+| OS | Store | How |258+|---|---|---|259+| macOS | Keychain | `/usr/bin/security`. Writing uses `security -i`, which reads its command from stdin, with the key hex-encoded (`-X`), so it's never in any process's arguments. |260+| Linux, BSD | Secret Service (GNOME Keyring, KWallet, KeePassXC) | libsecret's `secret-tool`, which reads the secret to store from stdin. |261+| Windows | Credential Manager | `CredReadW`, `CredWriteW`, `CredDeleteW` from advapi32 through `golang.org/x/sys/windows`, already in the module graph. |262+263+The alternatives were a keyring module (zalando/go-keyring, which does264+the same on macOS but brings godbus for Linux, or 99designs/keyring,265+which brings several backends and needs cgo for the macOS Keychain) or266+cgo bindings to Security.framework and libsecret, which would end the267+pure-Go build. Two268+small command-line programs and one system DLL cover the three OSes with269+no new module. The stores share a `Store` interface; tests use a270+recording runner or `keyring.Memory`, and the e2e binary is built with271+`-tags fakekeyring`, whose store is a file under `XDG_CONFIG_HOME`, so no272+test reaches a real keychain.273+274+## Themes275+276+`theme.Theme` is a set of style roles, each defined on the 16 ANSI colors277+and the default background and foreground (`theme.New`). The terminal278+theme sends them as ANSI numbers, so the terminal's palette applies. A279+color scheme (`theme.Palette`: VHS's embedded catalog, or a Ghostty,280+kitty or VHS JSON file in `themes/`) is drawn by `theme.FromPalette`,281+which maps each ANSI color to the scheme's, adds full-width bands for the282+bars and a screen background, and corrects contrast (see283+[themes.md](themes.md)). Bands are applied by `theme.Fill`, one pass over284+a rendered line's escape sequences that sets the band's colors at the285+start and after each reset and pads to the width; `View` fills the bar286+lines before overlays are composited and the whole screen after, so bars287+reach the edge under menus too. Charts take their colors from the scheme288+when there is one, and from the terminal's palette otherwise.289+230290 ## Telemetry231291 232292 `internal/telemetry` is off unless asked for. When on, spans, events and@@ -0,0 +1,254 @@
1+# Configuration2+3+012 reads one settings file, in the style of Ghostty's:4+5+| OS | File |6+|---|---|7+| Linux, BSD | `$XDG_CONFIG_HOME/012/config`, usually `~/.config/012/config` |8+| macOS | `~/Library/Application Support/012/config`, or `$XDG_CONFIG_HOME/012/config` when that's set |9+| Windows | `%AppData%\012\config`, or `%XDG_CONFIG_HOME%\012\config` when that's set |10+11+```12+# ~/.config/012/config13+theme = light:Builtin Solarized Light,dark:Dracula14+chart-images = true15+jev-api-key-command = op read op://Private/TypeSafe/credential16+config-file = ?local.conf17+```18+19+- One `key = value` per line. `#` starts a comment line; blank lines are20+ ignored. Quote a value (`" like this "`) to keep spaces at its ends.21+- An empty value (`theme =`) puts an option back to its default.22+- `config-file = path` reads another file after this one, relative to this23+ file's directory; its settings win over the ones around it. A leading24+ `?` (`config-file = ?local.conf`) makes it optional. Includes can nest;25+ a file that includes itself is skipped with a warning.26+- Unknown keys, bad values and missing includes never stop 012: it starts27+ with the defaults for those options and says what's wrong on the context28+ line (and in `012 config`).29+30+Settings are process-wide. Settings that belong to a workbook, such as31+decimal arithmetic, are saved in the workbook's `.012` file instead.32+33+**Precedence:** command-line flags win over environment variables, which34+win over the config file, which wins over the defaults. `012 config` shows35+where each value came from.36+37+**Secrets never go in this file.** The JEV API key lives in the OS38+credential store or comes from a command; see [JEV setup](jev.md#setup).39+40+## Commands41+42+| Command | Does |43+|---|---|44+| `012 config` | print the settings in effect, each with where it came from (file and line, variable, flag or default), secrets redacted, then any warnings |45+| `012 config path` | print the config file's path |46+| `012 config edit` | open the file in `$VISUAL` or `$EDITOR`, creating it with every option commented out if it doesn't exist |47+| `012 config default` | print a config file with every option at its default, commented out and described |48+| `012 config themes` | list the themes: built in and in the themes directory |49+| `012 config set-key` | store the JEV API key in the credential store, read from the terminal without echo or from stdin |50+| `012 config delete-key` | remove the JEV API key from the credential store |51+52+In the app, File > Settings has **Theme** (a picker with live preview),53+**Open config file** (in your editor, reloading when it closes),54+**Reload config** and **JEV API key**. Reloading applies the options marked55+"File > Settings > Reload config" below; the rest take effect on the next56+start.57+58+## Options59+60+<!-- Generated from internal/config/registry.go by `go test ./internal/config -update-docs`. Don't edit below. -->61+62+### Appearance63+64+#### `theme`65+66+Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.67+68+| | |69+|---|---|70+| Type | text |71+| Default | `terminal` |72+| Environment | `O12_THEME` |73+| Flag | `--theme` |74+| Applies | File > Settings > Reload config |75+76+#### `chart-images`77+78+Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). When false, charts are always text.79+80+| | |81+|---|---|82+| Type | true or false |83+| Default | `true` |84+| Environment | `O12_CHART_IMAGES` |85+| Applies | File > Settings > Reload config |86+87+#### `notifications`88+89+Send a desktop notification (OSC 9) when JEV answers or an import finishes while the terminal window is in the background.90+91+| | |92+|---|---|93+| Type | true or false |94+| Default | `true` |95+| Environment | `O12_NOTIFICATIONS` |96+| Applies | File > Settings > Reload config |97+98+#### `keymap`99+100+Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, visual selection and a : command line (File > Settings > Vim keys).101+102+| | |103+|---|---|104+| Type | one of `default`, `vim` |105+| Default | `default` |106+| Environment | `O12_KEYMAP` |107+| Applies | File > Settings > Reload config |108+109+### JEV functions110+111+#### `jev-api-key-command`112+113+A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or `pass show typesafe`. It runs the first time a sheet asks JEV something, without a shell, for up to 10 seconds, and its first line of output is the key; for pipes, write `sh -c '...'` yourself. The key itself never goes in this file.114+115+| | |116+|---|---|117+| Type | command |118+| Default | (empty) |119+| Applies | restart 012 |120+121+#### `jev-credential-store`122+123+Look for the API key in the OS credential store (macOS Keychain, Windows Credential Manager, the Secret Service on Linux), where `012 config set-key` and Settings put it.124+125+| | |126+|---|---|127+| Type | true or false |128+| Default | `true` |129+| Environment | `O12_JEV_CREDENTIAL_STORE` |130+| Applies | restart 012 |131+132+#### `jev-base-url`133+134+The TypeSafe service to ask; the SDK's default when empty. Must be https, except on localhost.135+136+| | |137+|---|---|138+| Type | URL |139+| Default | (empty) |140+| Environment | `TYPESAFE_BASE_URL` |141+| Applies | restart 012 |142+143+#### `jev-model`144+145+The JEV model to ask; the service's default when empty.146+147+| | |148+|---|---|149+| Type | text |150+| Default | (empty) |151+| Environment | `TYPESAFE_DEFAULT_MODEL` |152+| Applies | restart 012 |153+154+### Telemetry155+156+#### `log-file`157+158+Append telemetry events to this JSON log file. See docs/observability.md.159+160+| | |161+|---|---|162+| Type | path |163+| Default | (empty) |164+| Environment | `O12_LOG` |165+| Flag | `--log` |166+| Applies | restart 012 |167+168+#### `log-level`169+170+The least severe telemetry event recorded; debug adds every frame and command.171+172+| | |173+|---|---|174+| Type | one of `debug`, `info`, `warn`, `error` |175+| Default | `info` |176+| Environment | `O12_LOG_LEVEL` |177+| Applies | restart 012 |178+179+#### `otlp-endpoint`180+181+Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. The other OTEL_* variables still apply.182+183+| | |184+|---|---|185+| Type | URL |186+| Default | (empty) |187+| Environment | `OTEL_EXPORTER_OTLP_ENDPOINT` |188+| Flag | `--otlp` |189+| Applies | restart 012 |190+191+### 012 serve192+193+#### `serve-listen`194+195+The address 012 serve listens on. Anything but the loopback address lets other machines reach it (with an authorized key). See docs/ssh.md.196+197+| | |198+|---|---|199+| Type | host:port |200+| Default | `127.0.0.1:2312` |201+| Applies | restart 012 |202+203+#### `serve-authorized-keys`204+205+The public keys allowed to log in to 012 serve, in OpenSSH's authorized_keys format.206+207+| | |208+|---|---|209+| Type | path |210+| Default | `~/.ssh/authorized_keys` |211+| Applies | restart 012 |212+213+#### `serve-host-key`214+215+012 serve's private host key, generated when missing; ssh_host_ed25519_key in the config directory when empty.216+217+| | |218+|---|---|219+| Type | path |220+| Default | (empty) |221+| Applies | restart 012 |222+223+#### `serve-idle-timeout`224+225+End a 012 serve session that has had no input for this long; 0 never does.226+227+| | |228+|---|---|229+| Type | duration |230+| Default | `30m` |231+| Applies | restart 012 |232+233+#### `serve-max-sessions`234+235+How many 012 serve sessions may run at once; more are turned away.236+237+| | |238+|---|---|239+| Type | number |240+| Default | `8` |241+| Applies | restart 012 |242+243+### Config files244+245+#### `config-file`246+247+Read another config file after this one, relative to this file's directory. A leading `?` makes it optional: no warning when it doesn't exist.248+249+| | |250+|---|---|251+| Type | path |252+| Default | (empty) |253+| Repeatable | yes |254+| Applies | restart 012 |@@ -18,10 +18,15 @@ shows it is derived from the table rather than listing it again.
1818 | `ui.command` | an action: id, title, description, run, enabled, checked | key bindings (Sheets and vim), menu bar, context menus, palette, shortcuts help, the `:` command line and its completions |1919 | `fileio` formats (`formats.go`) | a format: name, extensions, labels, traits, importer, exporter | `Import`, `Export`, detection, import picker, File > Download, command line |2020 | `chart.types`, with `sheet.ChartTypes` | a type: name and order (sheet, saved in files), a layout drawing text and image (chart) | `chart.Draw`, `chart.Image`, chart editor, Insert > Chart |21-| theme roles | a role: dark and light styles on the 16 ANSI colors | every style in the UI |22-23-Adding a function, command, format or chart type means adding an entry24-(and its file), not editing switch statements elsewhere. When a list has to21+| theme roles (`theme.Theme`) | a role: dark and light styles on the 16 ANSI colors, with a contrast minimum for schemes (`minContrast`) | every style in the UI, drawn in the terminal's palette or any color scheme (`FromPalette`); `TestEveryThemeReadable` checks each role under all 349 schemes |22+| `config.Options` | an option: name, type, default, environment variables, flag, live or not, description, check | parsing and warnings, flags, `012 config` and its default file, [config.md](config.md), Reload config |23+24+Adding a function, command, format, chart type, option or role means25+adding an entry (and its file), not editing switch statements elsewhere.26+A new process-wide setting is a `config.Options` entry read with27+`Config.String`, `Bool`, `Int` or `Duration`; one that belongs to a28+workbook goes in the file instead (pattern 9). A new role goes in29+`theme.Theme` with its ANSI colors in `New`; schemes pick it up. When a list has to2530 be kept in sync by hand, turn it into a registry.2631 2732 ### 2. Components own their state@@ -16,11 +16,60 @@ background (cells show `Loading…`), are cached by question, and the context
1616 line shows the confidence for the selected cell. Data > Ask JEV again1717 re-asks the selection.1818 19-Set `TYPESAFE_API_KEY` in the environment or a `.env` file in the current20-directory or next to the sheet; `TYPESAFE_BASE_URL` and21-`TYPESAFE_DEFAULT_MODEL` are optional. Without a key the functions show22-`#N/A` and say why. `JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive`23-checks the real service.19+Without a key the functions show `#N/A` and say how to add one.20+`JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive` checks the real21+service.22+23+## Setup24+25+The API key never goes in a file 012 reads or on a command line. Store it26+once in your operating system's credential store:27+28+```sh29+012 config set-key # asks for the key without echoing it30+op read op://Private/TypeSafe/credential | 012 config set-key # or pipe it in31+012 config delete-key # remove it32+```33+34+or in the app, File > Settings > JEV API key, which asks on the context35+line with the key masked and turns JEV on at once. `012 config` says where36+the key would come from, without showing it.37+38+012 looks for the key in this order:39+40+1. **`TYPESAFE_API_KEY`** in the environment, for CI, containers and41+ one-off runs.42+2. **The credential store**: the macOS Keychain, the Windows Credential43+ Manager, or the Secret Service on Linux and the BSDs (GNOME Keyring,44+ KWallet, KeePassXC; this needs `secret-tool`, in the `libsecret-tools`45+ package on Debian and Ubuntu). The item is service `012`, account46+ `jev-api-key`. `jev-credential-store = false` in the config skips it.47+3. **`jev-api-key-command`** in the config: a command that prints the key,48+ for password managers:49+50+ ```51+ jev-api-key-command = op read op://Private/TypeSafe/credential52+ jev-api-key-command = pass show typesafe53+ jev-api-key-command = sh -c 'gpg -dq ~/.secrets/typesafe.gpg | head -1'54+ ```55+56+ It runs the first time a sheet asks JEV something, not at every57+ start, for at most 10 seconds, and the first line it prints is the key.58+ It runs without a shell: the value is split into words, with quotes59+ grouping words and a backslash escaping the next character, and60+ nothing is expanded (`$HOME`, `~`, `*`, pipes and `;` are passed on61+ literally). For a pipeline, write `sh -c '...'` yourself.62+63+The service's address and model come from the config file or the64+environment only: `jev-base-url` (or `TYPESAFE_BASE_URL`), which must be65+https unless it's this machine, and `jev-model` (or66+`TYPESAFE_DEFAULT_MODEL`). See [config.md](config.md).67+68+**`.env` files are no longer read.** A `.env` next to a downloaded sheet69+could set `TYPESAFE_BASE_URL` and send your key to someone else's server.70+If the directory 012 starts in has a `.env` with `TYPESAFE_API_KEY`, the71+context line suggests `012 config set-key`; 012 never reads the key from72+it.2473 2574 ## How requests behave2675 @@ -33,7 +82,8 @@ checks the real service.
3382 than two labels, more than the service's 255 labels or 10 score levels)3483 show as errors at once and are never sent.3584 - A failed request shows `#ERROR!`, and the context line says why.36-- 012 never logs or saves the API key; `.env` files are read for37- `TYPESAFE_*` settings only.85+- 012 never logs, shows or saves the API key anywhere but the credential86+ store, and never passes it as a command-line argument: the macOS87+ Keychain and the Secret Service get it on stdin.3888 3989 The client is [typesafe-go](https://github.com/FelineStateMachine/typesafe-go).@@ -25,6 +25,10 @@ O12_LOG_LEVEL=debug # also one event per frame
2525 OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 012 budget.012 # the same2626 ```2727 28+Or keep them in the [config file](config.md) as `log-file`, `log-level`29+and `otlp-endpoint`; flags win over the environment, which wins over the30+file.31+2832 The two can be on together or separately. Stdout belongs to the2933 terminal UI, so events go to the file, appended one JSON object per3034 line, and/or to the OTLP endpoint (see [OTLP](#otlp) below). With@@ -26,7 +26,12 @@ by libghostty itself from the modes the app turned on, so they are the bytes
2626 Ghostty would send. Tests assert on what a user would see: screen text,2727 cursor position, window title, hyperlinks, which screen is active after2828 quitting. A fake TypeSafe server answers JEV questions, and the harness29-clears `TYPESAFE_API_KEY` so tests never reach the real service.29+clears `TYPESAFE_API_KEY` so tests never reach the real service. Every30+session gets its own config directory (`XDG_CONFIG_HOME`, with an31+optional config file), and the binary is built with `-tags fakekeyring`,32+whose credential store is a file there, so tests never read your config33+or touch your keychain. Unit tests do the same with `ui.Settings` and34+`keyring.Memory`.3035 3136 `e2e/ssh_test.go` runs `012 serve` and reaches it with the system's3237 `ssh` client inside the same libghostty terminal, so the server path@@ -0,0 +1,104 @@
1+# Themes2+3+A theme is a terminal color scheme: the 16 ANSI colors plus a background,4+a foreground and a selection color, the same shape Ghostty, kitty, iTerm2,5+Alacritty and VHS themes have. 012 has no theme format of its own to learn:6+any scheme works, and so does any Ghostty theme file.7+8+```9+# ~/.config/012/config10+theme = terminal # the default11+theme = Dracula # one scheme12+theme = light:Catppuccin Latte,dark:Catppuccin Mocha # follow the terminal13+```14+15+- **`terminal`** (the default) draws with the terminal's own palette, as16+ ANSI color numbers, with a dark and a light variant picked from the17+ terminal's background. It looks the way your terminal is set up.18+- **Any other name** is a color scheme. 012 draws its roles in the19+ scheme's colors (true color), fills the whole screen with the scheme's20+ background, and draws the menu bar, the column headers and the status21+ line as solid bands, in the manner of 1-2-3 and classic terminal22+ programs. The terminal's own palette is never changed.23+- **`light:A,dark:B`** picks A on a light terminal and B on a dark one,24+ and switches when the terminal does (for example with the system's25+ appearance, in terminals that report it).26+27+File > Settings > Theme opens a picker: type to search the names, or28+"dark" or "light"; the highlighted theme is drawn live, Enter keeps it and29+writes `theme = ...` to the config file, and Esc goes back to the theme you30+had. With a `light:`/`dark:` pair, the picker changes the one in use.31+`012 config themes` lists every theme, with a `*` on the current one.32+33+## Built-in schemes34+35+012 embeds the 348 schemes of [VHS](https://github.com/charmbracelet/vhs)36+(Dracula, Nord, Gruvbox, Catppuccin, Solarized, Tokyo Night, One Dark,37+Rose Pine and hundreds more), so names are the ones VHS's `Set Theme`38+takes, plus **1-2-3 Classic**: CGA colors on a blue screen. Names match39+without regard to case, and then without regard to spaces, hyphens and40+underscores, so `tokyo-night` finds `TokyoNight`.41+42+## Your own schemes43+44+Put a file in the `themes` directory next to the config file45+(`~/.config/012/themes/` on Linux; `012 config path` shows where the46+config is) and use its file name as the theme. A file with a built-in's47+name replaces it. Three formats are read:48+49+- **Ghostty** (copy any Ghostty theme unchanged):50+51+ ```52+ palette = 0=#1d1f2153+ palette = 1=#cc666654+ # ... through 1555+ background = #1d1f2156+ foreground = #c5c8c657+ selection-background = #373b4158+ ```59+60+- **kitty**: `color0 #1d1f21` through `color15`, `background`,61+ `foreground` and `selection_background`.62+- **VHS JSON**: one scheme or a list, with `black` ... `brightWhite`,63+ `background`, `foreground`, `selection` and `meta.isDark` (a `.json`64+ file; `purple` and `selectionBackground` are accepted too).65+66+A scheme needs a background and a foreground; ANSI colors it leaves out67+are xterm's. Other keys (fonts, cursor colors) are ignored.68+69+## How roles get their colors70+71+Every role in `internal/ui/theme` (the cell pointer, selection, headers,72+menus, errors, links, chart series and so on) is defined on the ANSI73+colors and the background and foreground: the pointer is black on cyan,74+headers are bright white on bright black (black on white on light75+terminals), errors are red. The terminal theme sends those color numbers;76+a scheme replaces each with its own color. Then, so every scheme reads77+well:78+79+- the selection uses the scheme's selection color, when it has one;80+- the menu bar and status line are the background moved 12% toward the81+ text color; the column header row is filled with the header color;82+- a background role too close to the screen's background is moved apart;83+- text below its contrast minimum against its background (WCAG 4.5:1 for84+ text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items)85+ is moved toward the scheme's text color, or toward black or white, just86+ far enough to reach it.87+88+A test runs every built-in scheme through this and checks those minimums89+for cell text, text on the bars, the selection, the headers and every90+other role (`TestEveryThemeReadable`). Charts, as text and as kitty91+images, use the scheme's colors too.92+93+The roles for the bars are `MenuBarRow`, `FormulaBarRow`, `ContextRow`,94+`ColumnHeaderRow` and `StatusBarRow` (a background and a text color for95+the whole line, out to the terminal's edge, under overlays too), with96+`RowHeader` for the row numbers and `Screen` for everything else. The97+terminal theme leaves them empty, so it looks as it always has.98+99+## Credits100+101+The built-in schemes are VHS's `themes.json` (MIT License, Copyright (c)102+2022-2023 Charmbracelet, Inc.; see [NOTICE](../NOTICE)), which credits103+each scheme's authors in its `meta.credits`. The contrast correction is104+adapted from the theme package of puzzletea, by the same author.@@ -0,0 +1,111 @@
1+package e2e2+3+import (4+ "os"5+ "os/exec"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+// Problems in the config file show on the context line; 012 starts12+// anyway, with defaults for what's wrong.13+func TestConfigWarnings(t *testing.T) {14+ s := startWith(t, options{config: "theme = Dracula\nbogus = 1\nchart-images = maybe\n"})15+ s.waitFor(`Config: config:2: unknown key "bogus" (and 1 more; run 012 config)`)16+ if !strings.Contains(s.html(), "background:#1e1f29") {17+ t.Error("the valid theme line was not applied")18+ }19+}20+21+// openTheme opens File > Settings > Theme.22+func openTheme(s *session) {23+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>")24+ s.waitFor("Pick a color theme")25+ s.keys("<enter>")26+ s.waitFor("─ Theme ─")27+}28+29+// The theme picker previews the highlighted theme, Esc restores the one30+// there was, and Enter keeps and saves it, so the next start has it.31+func TestThemePicker(t *testing.T) {32+ cfg := t.TempDir()33+ s := startWith(t, options{configDir: cfg})34+ openTheme(s)35+ s.keys("Dracula")36+ s.eventually("Dracula previewed", func() bool { return strings.Contains(s.html(), "background:#1e1f29") })37+ s.keys("<esc>")38+ s.eventually("the terminal theme restored", func() bool { return !strings.Contains(s.html(), "#1e1f29") })39+ s.waitFor("READY")40+41+ openTheme(s)42+ s.keys("Dracula", "<enter>")43+ s.waitFor("Theme Dracula, saved in")44+ data, err := os.ReadFile(filepath.Join(cfg, "012", "config"))45+ if err != nil || string(data) != "theme = Dracula\n" {46+ t.Errorf("config file %q %v", data, err)47+ }48+ s.keys("<ctrl+q>")49+ s.waitExit()50+51+ r := startWith(t, options{configDir: cfg})52+ r.eventually("Dracula at start", func() bool { return strings.Contains(r.html(), "background:#1e1f29") })53+}54+55+// A .env with the key next to the sheet is never read: JEV stays off,56+// and the context line says how to store the key instead.57+func TestDotEnvNotRead(t *testing.T) {58+ srv, requests := fakeTypeSafe(t)59+ dir := t.TempDir()60+ os.WriteFile(filepath.Join(dir, ".env"), []byte("TYPESAFE_API_KEY=test-key\nTYPESAFE_BASE_URL="+srv.URL+"\n"), 0o600)61+ s := startWith(t, options{dir: dir})62+ s.waitFor("012 no longer reads TYPESAFE_API_KEY from .env files")63+ s.keys(`=JEV.TEST("x", "Q")`, "<enter>", "<up>")64+ s.waitFor("JEV functions need an API key")65+ if n := requests.Load(); n != 0 {66+ t.Errorf("%d requests with a key from .env", n)67+ }68+}69+70+// 012 config set-key stores the key (here in the tests' stand-in for the71+// credential store); the next start finds it there and JEV works.72+func TestSetKeyThenJEV(t *testing.T) {73+ srv, requests := fakeTypeSafe(t)74+ cfg := t.TempDir()75+ cmd := exec.Command(binPath, "config", "set-key")76+ cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+cfg, "TYPESAFE_API_KEY=")77+ cmd.Stdin = strings.NewReader("test-key\n")78+ out, err := cmd.CombinedOutput()79+ if err != nil || !strings.Contains(string(out), "Saved the JEV API key in the test credential store") || strings.Contains(string(out), "test-key") {80+ t.Fatalf("set-key: %v\n%s", err, out)81+ }82+ s := startWith(t, options{configDir: cfg, env: []string{"TYPESAFE_BASE_URL=" + srv.URL}})83+ s.keys(`=JEV.TEST("The box was crushed", "Is this a complaint?")`, "<enter>")84+ s.waitForLine(gridRow1, " 1 TRUE")85+ if requests.Load() != 1 {86+ t.Errorf("%d requests", requests.Load())87+ }88+}89+90+// File > Settings > JEV API key takes the key masked, stores it and turns91+// JEV on at once.92+func TestAPIKeyPrompt(t *testing.T) {93+ srv, _ := fakeTypeSafe(t)94+ cfg := t.TempDir()95+ s := startWith(t, options{configDir: cfg, env: []string{"TYPESAFE_BASE_URL=" + srv.URL}})96+ s.keys(`=JEV.TEST("The box was crushed", "Is this a complaint?")`, "<enter>")97+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")98+ s.waitFor("Store the TypeSafe API key")99+ s.keys("<enter>", "test-key")100+ s.waitFor("TypeSafe API key: ••••••••")101+ if strings.Contains(s.screen(), "test-key") {102+ t.Fatal("the key is on screen")103+ }104+ s.keys("<enter>")105+ s.waitFor("API key saved in the test credential store")106+ s.waitForLine(gridRow1, " 1 TRUE")107+ data, err := os.ReadFile(filepath.Join(cfg, "012", "test-credential-store"))108+ if err != nil || string(data) != "test-key" {109+ t.Errorf("stored %q %v", data, err)110+ }111+}@@ -27,7 +27,9 @@ func TestMain(m *testing.M) {
2727 panic(err)2828 }2929 binPath = filepath.Join(dir, "012")30- build := exec.Command("go", "build", "-o", binPath, "./cmd/012")30+ // fakekeyring swaps the OS credential store for a file under31+ // XDG_CONFIG_HOME, which every session points at a temporary directory.32+ build := exec.Command("go", "build", "-tags", "fakekeyring", "-o", binPath, "./cmd/012")3133 build.Dir = ".."3234 build.Env = append(os.Environ(), "CGO_ENABLED=0")3335 if out, err := build.CombinedOutput(); err != nil {@@ -68,6 +70,11 @@ type options struct {
6870 light bool // use the light reference palette6971 env []string // extra environment, e.g. a fake JEV endpoint7072 jev bool // answer JEV functions with a fake service (screens)73+ // config is the session's config file. Every session has its own74+ // config directory (XDG_CONFIG_HOME), so the user's is never read;75+ // configDir shares one between sessions.76+ config string77+ configDir string7178 // graphics turns on the kitty graphics protocol, so charts become7279 // images; off by default, so screens show the text charts every7380 // terminal gets.@@ -130,6 +137,7 @@ func startWith(t *testing.T, o options, args ...string) *session {
130137 // Tests never reach the real JEV service: the key is cleared unless a131138 // test points JEV at a fake server through o.env.132139 s.cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "TYPESAFE_API_KEY=", "TYPESAFE_BASE_URL=")140+ s.cmd.Env = append(s.cmd.Env, configEnv(t, o)...)133141 s.cmd.Env = append(s.cmd.Env, o.env...)134142 s.pty, err = pty.StartWithSize(s.cmd, &pty.Winsize{Cols: s.cols, Rows: s.rows})135143 if err != nil {@@ -463,3 +471,23 @@ var charKeys = func() map[rune]physKey {
463471 }464472 return m465473 }()474+475+// configEnv gives a session its own config directory, with o.config as476+// its config file, and turns off the telemetry and theme variables a477+// developer's shell may set.478+func configEnv(t *testing.T, o options) []string {479+ t.Helper()480+ dir := o.configDir481+ if dir == "" {482+ dir = t.TempDir()483+ }484+ if o.config != "" {485+ if err := os.MkdirAll(filepath.Join(dir, "012"), 0o700); err != nil {486+ t.Fatal(err)487+ }488+ if err := os.WriteFile(filepath.Join(dir, "012", "config"), []byte(o.config), 0o600); err != nil {489+ t.Fatal(err)490+ }491+ }492+ return []string{"XDG_CONFIG_HOME=" + dir, "O12_THEME=", "TYPESAFE_DEFAULT_MODEL="}493+}@@ -81,7 +81,7 @@ func TestJEVFunctionsAgainstFakeService(t *testing.T) {
8181 func TestJEVOffWithoutKey(t *testing.T) {8282 s := start(t, "")8383 s.keys(`=JEV.TEST("x", "Q")`, "<enter>", "<up>")84- s.waitFor("JEV functions need TYPESAFE_API_KEY")84+ s.waitFor("JEV functions need an API key")8585 if !strings.Contains(s.line(gridRow1), "#N/A") {8686 t.Errorf("A1 shows %q", s.line(gridRow1))8787 }@@ -238,6 +238,38 @@ var screens = []screen{
238238 s.keys("<alt+f>", "<up>", "<up>", "<right>")239239 s.waitFor("Compute money exactly")240240 }},241+ // Themes: color schemes draw bars and the screen in their own colors.242+ {name: "theme-classic", opts: options{config: "theme = 1-2-3 Classic\n"}, setup: func(s *session) {243+ budget(s)244+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")245+ s.waitFor("Sum 2158.4")246+ }},247+ {name: "theme-classic-narrow-menu", opts: options{cols: 61, rows: 16, config: "theme = 1-2-3 Classic\n"}, setup: func(s *session) {248+ budget(s)249+ s.keys("<alt+f>", "<down>", "<down>", "<down>")250+ s.waitFor("Save the sheet")251+ }},252+ {name: "theme-dark", opts: options{config: "theme = TokyoNight\n"}, setup: formatted},253+ {name: "theme-light-menu", opts: options{light: true, config: "theme = light:Catppuccin Latte,dark:Catppuccin Mocha\n"}, setup: func(s *session) {254+ budget(s)255+ s.keys("<alt+f>", "<down>", "<down>", "<down>")256+ s.waitFor("Save the sheet")257+ }},258+ {name: "theme-picker", setup: func(s *session) {259+ budget(s)260+ openTheme(s)261+ s.keys("gruvbox")262+ s.waitFor("│ › gruvbox")263+ }},264+ {name: "api-key-prompt", setup: func(s *session) {265+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")266+ s.waitFor("Store the TypeSafe API key")267+ s.keys("<enter>", "ts-demo-0123456789")268+ s.waitFor("TypeSafe API key: ••••••••••••••••••")269+ }},270+ {name: "config-warning", opts: options{config: "theme = Nord\nbogus = 1\n"}, setup: func(s *session) {271+ s.waitFor(`unknown key "bogus"`)272+ }},241273 {name: "menu-freeze", setup: func(s *session) {242274 inventory(s)243275 s.keys("<alt+v>", "<right>", "<down>")@@ -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">KEY</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>3+TypeSafe API key: •••••••••••••••••• <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)">save</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)">cancel</span>4+<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>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>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>8+<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>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>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>11+<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> untitled <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> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">F</span><span style="color:#d8dee9;background:#424854">ile</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">E</span><span style="color:#d8dee9;background:#424854">dit</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">V</span><span style="color:#d8dee9;background:#424854">iew</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">I</span><span style="color:#d8dee9;background:#424854">nsert</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854">F</span><span style="color:#d8dee9;background:#424854;text-decoration:underline">o</span><span style="color:#d8dee9;background:#424854">rmat</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">D</span><span style="color:#d8dee9;background:#424854">ata</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854;text-decoration:underline">H</span><span style="color:#d8dee9;background:#424854">elp</span><span style="background:#424854"> </span><span style="background:#88c0d0"> </span><span style="color:#3b4252;background:#88c0d0;font-weight:bold">READY</span><span style="background:#88c0d0"> </span>2+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">A1</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>3+<span style="color:#ebcb8b;background:#2e3440">Config:</span><span style="background:#2e3440"> </span><span style="color:#ebcb8b;background:#2e3440">config:2:</span><span style="background:#2e3440"> </span><span style="color:#ebcb8b;background:#2e3440">unknown</span><span style="background:#2e3440"> </span><span style="color:#ebcb8b;background:#2e3440">key</span><span style="background:#2e3440"> </span><span style="color:#ebcb8b;background:#2e3440">"bogus"</span><span style="background:#2e3440"> </span>4+<span style="background:#4c566a"> </span><span style="background:#88c0d0"> </span><span style="color:#3b4252;background:#88c0d0;font-weight:bold">A</span><span style="background:#88c0d0"> </span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">B</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">C</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">D</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">E</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">F</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">G</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">H</span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">I</span><span style="background:#4c566a"> </span>5+<span style="background:#88c0d0"> </span><span style="color:#3b4252;background:#88c0d0;font-weight:bold">1</span><span style="background:#88c0d0"> </span><span style="background:#2e3440"> </span>6+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">2</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>7+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">3</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>8+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">4</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>9+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">5</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>10+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">6</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>11+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">7</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>12+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">8</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>13+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">9</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>14+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">10</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>15+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">11</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>16+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">12</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>17+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">13</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>18+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">14</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>19+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">15</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>20+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">16</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>21+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">17</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>22+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">18</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>23+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">19</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>24+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">20</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>25+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">21</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>26+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">22</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>27+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">23</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>28+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">24</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>29+<span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">25</span><span style="background:#4c566a"> </span><span style="background:#2e3440"> </span>30+<span style="background:#88c0d0"> </span><span style="color:#3b4252;background:#88c0d0;font-weight:bold">Sheet1</span><span style="background:#88c0d0"> </span><span style="background:#424854"> </span><span style="color:#747d8e;background:#424854">+</span><span style="background:#424854"> </span><span style="color:#747d8e;background:#424854">│</span><span style="background:#424854"> </span><span style="color:#d8dee9;background:#424854">untitled</span><span style="background:#424854"> </span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">Ctrl+K</span><span style="background:#4c566a"> </span><span style="background:#424854"> </span><span style="color:#747d8e;background:#424854">search</span><span style="background:#424854"> </span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">Ctrl+/</span><span style="background:#4c566a"> </span><span style="background:#424854"> </span><span style="color:#747d8e;background:#424854">shortcuts</span><span style="background:#424854"> </span><span style="background:#4c566a"> </span><span style="color:#eceff4;background:#4c566a">F10</span><span style="background:#4c566a"> </span><span style="background:#424854"> </span><span style="color:#747d8e;background:#424854">menu</span></pre>\ No newline at end of file@@ -10,13 +10,13 @@
1010 <span style="color:var(--vt-palette-8)">├─────────────────────┤┌────────────────────────┐</span>1111 <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>1212 <span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> Vim keys <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>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>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> Theme <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> JEV API key <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>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> Open config file <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> Reload config <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>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>2121 <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>2222 <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>@@ -12,5 +12,5 @@
1212 <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)">Data</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Sor…</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)">Select</span> <span style="color:var(--vt-palette-8)">chart</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)">25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</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)">25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</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@@ -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> 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>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> 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>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> Insert rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Insert</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)">16</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">110</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)">18</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">114</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>@@ -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> 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>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> 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>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> Insert rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Insert</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)">16</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</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)">18</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</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>@@ -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> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</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> Re<span style="color:var(--vt-palette-3);font-weight:bold">se</span>t co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umn width <span style="color:var(--vt-palette-8)">Format</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> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt co<span style="color:var(--vt-palette-3);font-weight:bold">l</span>umns right <span style="color:var(--vt-palette-8)">Insert</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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</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)">22</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</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>@@ -13,8 +13,8 @@
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> Download as SQLite <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>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> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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> Vim keys <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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> Quit <span style="color:var(--vt-palette-8)">File</span> <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>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)">110</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">110</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> Theme <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</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)">114</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">114</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>@@ -0,0 +1,16 @@
1+<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>16+<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@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1fb4">ile</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">READY</span><span style="background:#00aaaa"> </span>2+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">B3:B5</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">1450</span><span style="background:#0000aa"> </span>3+<span style="background:#0000aa"> </span>4+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">A</span><span style="background:#555555"> </span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa;font-weight:bold">B</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><span style="color:#ffffff;background:#555555">F</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">G</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">H</span><span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">I</span><span style="background:#555555"> </span>5+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">1</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Household</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">budget</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">2026</span><span style="background:#0000aa"> </span>6+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">2</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>7+<span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa;font-weight:bold">3</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Rent</span><span style="background:#0000aa"> </span><span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa">1450</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span>8+<span style="background:#00aaaa"> </span><span style="color:#0000aa;background:#00aaaa">4</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Groceries</span><span style="background:#00aaaa"> </span><span style="color:#0000aa;background:#00aaaa">612.4</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span>9+<span style="background:#00aaaa"> </span><span style="color:#0000aa;background:#00aaaa">5</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Transit</span><span style="background:#0000aa"> </span><span style="background:#00aaaa"> </span><span style="color:#0000aa;background:#00aaaa">96</span><span style="background:#00aaaa"> </span><span style="background:#0000aa"> </span>10+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">6</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Savings</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">r</span><span style="background:#0000aa"> </span><span style="color:#d47f7f;background:#0000aa;text-decoration:underline wavy #aa0000">#DIV/0!</span><span style="background:#0000aa"> </span>11+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">7</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">Total</span><span style="background:#0000aa"> </span><span style="color:#ffffff;background:#0000aa">2158.4</span><span style="background:#0000aa"> </span>12+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">8</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>13+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">9</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>14+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">10</span><span style="background:#555555"> </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>16+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">12</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>17+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">13</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>18+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">14</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>19+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">15</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>20+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">16</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>21+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">17</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>22+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">18</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>23+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">19</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>24+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">20</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>25+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">21</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>26+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">22</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>27+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">23</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>28+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">24</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>29+<span style="background:#555555"> </span><span style="color:#ffffff;background:#555555">25</span><span style="background:#555555"> </span><span style="background:#0000aa"> </span>30+<span style="background:#00aaaa"> </span><span style="color:#000000;background:#00aaaa;font-weight:bold">Sheet1</span><span style="background:#00aaaa"> </span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">+</span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">│</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">untitled</span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">modified</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4;font-weight:bold">B3:B5</span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">Sum</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">2158.4</span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">Avg</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">719.47</span><span style="background:#1f1fb4"> </span><span style="color:#787878;background:#1f1fb4">Count</span><span style="background:#1f1fb4"> </span><span style="color:#ffffff;background:#1f1fb4">3</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">F</span><span style="color:#888ca5;background:#262631">ile</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">E</span><span style="color:#888ca5;background:#262631">dit</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">V</span><span style="color:#888ca5;background:#262631">iew</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">I</span><span style="color:#888ca5;background:#262631">nsert</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631">F</span><span style="color:#888ca5;background:#262631;text-decoration:underline">o</span><span style="color:#888ca5;background:#262631">rmat</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">D</span><span style="color:#888ca5;background:#262631">ata</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;text-decoration:underline">H</span><span style="color:#888ca5;background:#262631">elp</span><span style="background:#262631"> </span><span style="background:#7dcfff"> </span><span style="color:#363b54;background:#7dcfff;font-weight:bold">READY</span><span style="background:#7dcfff"> </span>2+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">A7:D7</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">Total</span><span style="background:#16161e"> </span>3+<span style="color:#7a7e9a;background:#16161e">Bold</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">on</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">for</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">A7:D7</span><span style="background:#16161e"> </span>4+<span style="background:#363b54"> </span><span style="background:#7dcfff"> </span><span style="color:#363b54;background:#7dcfff;font-weight:bold">A</span><span style="background:#7dcfff"> </span><span style="background:#7aa2f7"> </span><span style="color:#343951;background:#7aa2f7">B</span><span style="background:#7aa2f7"> </span><span style="color:#343951;background:#7aa2f7">C</span><span style="background:#7aa2f7"> </span><span style="color:#343951;background:#7aa2f7">D</span><span style="background:#7aa2f7"> </span><span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">E</span><span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">F</span><span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">G</span><span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">H</span><span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">I</span><span style="background:#363b54"> </span>5+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">1</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">Bills</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">for</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">October</span><span style="background:#16161e"> </span>6+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">2</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">Item</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">Due</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">Amount</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-weight:bold">Share</span><span style="background:#16161e"> </span>7+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">3</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">Rent</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">10/1/2026</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">$1,450.00</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">65.96%</span><span style="background:#16161e"> </span>8+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">4</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">Food</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">9/28/2026</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">$612.40</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">27.86%</span><span style="background:#16161e"> </span>9+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">5</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">Transit</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">9/30/2026</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">$96.00</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e">4.37%</span><span style="background:#16161e"> </span>10+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">6</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;text-decoration:line-through">Gym</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;text-decoration:line-through">10/5/2026</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;text-decoration:line-through">$40.00</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;text-decoration:line-through">1.82%</span><span style="background:#16161e"> </span>11+<span style="background:#7dcfff"> </span><span style="color:#363b54;background:#7dcfff;font-weight:bold">7</span><span style="background:#7dcfff"> </span><span style="color:#363b54;background:#7dcfff;font-weight:bold">Total</span><span style="background:#7dcfff"> </span><span style="background:#7aa2f7"> </span><span style="color:#343951;background:#7aa2f7;font-weight:bold">$2,198.40</span><span style="background:#7aa2f7"> </span><span style="color:#343951;background:#7aa2f7;font-weight:bold">100.00%</span><span style="background:#7aa2f7"> </span><span style="background:#16161e"> </span>12+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">8</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>13+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">9</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-style:italic">Gym</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-style:italic">cancelled</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-style:italic">from</span><span style="background:#16161e"> </span><span style="color:#7a7e9a;background:#16161e;font-style:italic">November</span><span style="background:#16161e"> </span>14+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">10</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>15+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">11</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>16+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">12</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>17+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">13</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>18+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">14</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>19+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">15</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>20+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">16</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>21+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">17</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>22+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">18</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>23+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">19</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>24+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">20</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>25+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">21</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>26+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">22</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>27+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">23</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>28+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">24</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>29+<span style="background:#363b54"> </span><span style="color:#acb0d0;background:#363b54">25</span><span style="background:#363b54"> </span><span style="background:#16161e"> </span>30+<span style="background:#7dcfff"> </span><span style="color:#363b54;background:#7dcfff;font-weight:bold">Sheet1</span><span style="background:#7dcfff"> </span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">+</span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">│</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631">untitled</span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">modified</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631;font-weight:bold">A7:D7</span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">Sum</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631">2199.4</span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">Avg</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631">1099.7</span><span style="background:#262631"> </span><span style="color:#5d627c;background:#262631">Count</span><span style="background:#262631"> </span><span style="color:#888ca5;background:#262631">3</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<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>17+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">13</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>18+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">14</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>19+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">15</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>20+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">16</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>21+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">17</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>22+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">18</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>23+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">19</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>24+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">20</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>25+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">21</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>26+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">22</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>27+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">23</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>28+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">24</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>29+<span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">25</span><span style="background:#acb0be"> </span><span style="background:#eff1f5"> </span>30+<span style="color:#4c4f69;background:#dadde4">Save</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4">the</span><span style="background:#dadde4"> </span><span style="color:#4c4f69;background:#dadde4">sheet</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Arrows</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">move</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Enter</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">choose</span><span style="background:#dadde4"> </span><span style="background:#acb0be"> </span><span style="color:#414354;background:#acb0be">Esc</span><span style="background:#acb0be"> </span><span style="background:#dadde4"> </span><span style="color:#5c5f77;background:#dadde4">close</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">F</span><span style="color:#ebdbb2;background:#3f3d39">ile</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">E</span><span style="color:#ebdbb2;background:#3f3d39">dit</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">V</span><span style="color:#ebdbb2;background:#3f3d39">iew</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">I</span><span style="color:#ebdbb2;background:#3f3d39">nsert</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">F</span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">o</span><span style="color:#ebdbb2;background:#3f3d39">rmat</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">D</span><span style="color:#ebdbb2;background:#3f3d39">ata</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39;text-decoration:underline">H</span><span style="color:#ebdbb2;background:#3f3d39">elp</span><span style="background:#3f3d39"> </span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold">MENU</span><span style="background:#689d6a"> </span>2+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">B7</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">=SUM(B</span><span style="color:#928374;background:#282828">┌─</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828;font-weight:bold">Theme</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">──────────────────────────────────────────────────────┐</span><span style="background:#282828"> </span>3+<span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828;font-weight:bold">›</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">gruvbox</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>4+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">A</span><span style="background:#928374"> </span><span style="background:#689d6a"> </span><span style="color:#928374;background:#282828">├──────────────────────────────────────────────────────────────┤</span><span style="background:#928374"> </span><span style="color:#211e19;background:#928374">I</span><span style="background:#928374"> </span>5+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">1</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Household</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">b</span><span style="color:#928374;background:#282828">│</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold;text-decoration:underline">Gruvbox</span><span style="color:#282828;background:#689d6a">Dark</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">dark</span><span style="background:#689d6a"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>6+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">2</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>7+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">3</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Rent</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="color:#ebdbb2;background:#282828">DarkHard</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">dark</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>8+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">4</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Groceries</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">└───────────────────────────────────────────────────</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">3</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">of</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">350</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">─┘</span><span style="background:#282828"> </span>9+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">5</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Transit</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">96</span><span style="background:#282828"> </span>10+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">6</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Savings</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">r</span><span style="background:#282828"> </span><span style="color:#d9705a;background:#282828;text-decoration:underline wavy #cc241d">#DIV/0!</span><span style="background:#282828"> </span>11+<span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold">7</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Total</span><span style="background:#282828"> </span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">2158.4</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span>12+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">8</span><span style="background:#928374"> </span><span style="background:#282828"> </span>13+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">9</span><span style="background:#928374"> </span><span style="background:#282828"> </span>14+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">10</span><span style="background:#928374"> </span><span style="background:#282828"> </span>15+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">11</span><span style="background:#928374"> </span><span style="background:#282828"> </span>16+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">12</span><span style="background:#928374"> </span><span style="background:#282828"> </span>17+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">13</span><span style="background:#928374"> </span><span style="background:#282828"> </span>18+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">14</span><span style="background:#928374"> </span><span style="background:#282828"> </span>19+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">15</span><span style="background:#928374"> </span><span style="background:#282828"> </span>20+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">16</span><span style="background:#928374"> </span><span style="background:#282828"> </span>21+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">17</span><span style="background:#928374"> </span><span style="background:#282828"> </span>22+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">18</span><span style="background:#928374"> </span><span style="background:#282828"> </span>23+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">19</span><span style="background:#928374"> </span><span style="background:#282828"> </span>24+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">20</span><span style="background:#928374"> </span><span style="background:#282828"> </span>25+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">21</span><span style="background:#928374"> </span><span style="background:#282828"> </span>26+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">22</span><span style="background:#928374"> </span><span style="background:#282828"> </span>27+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">23</span><span style="background:#928374"> </span><span style="background:#282828"> </span>28+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">24</span><span style="background:#928374"> </span><span style="background:#282828"> </span>29+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">25</span><span style="background:#928374"> </span><span style="background:#282828"> </span>30+<span style="color:#ebdbb2;background:#3f3d39">A</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">dark</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">color</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">scheme,</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">drawn</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">in</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">its</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">own</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">colors</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">with</span><span style="background:#3f3d39"> </span><span style="color:#ebdbb2;background:#3f3d39">solid…</span><span style="background:#3f3d39"> </span><span style="background:#928374"> </span><span style="color:#211e19;background:#928374">Up/Down</span><span style="background:#928374"> </span><span style="background:#3f3d39"> </span><span style="color:#928374;background:#3f3d39">move</span><span style="background:#3f3d39"> </span><span style="background:#928374"> </span><span style="color:#211e19;background:#928374">Enter</span><span style="background:#928374"> </span><span style="background:#3f3d39"> </span><span style="color:#928374;background:#3f3d39">keep</span><span style="background:#3f3d39"> </span><span style="background:#928374"> </span><span style="color:#211e19;background:#928374">Esc</span><span style="background:#928374"> </span><span style="background:#3f3d39"> </span><span style="color:#928374;background:#3f3d39">close</span></pre>\ No newline at end of file@@ -10,11 +10,13 @@ require (
1010 github.com/FelineStateMachine/typesafe-go v0.2.01111 github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd91212 github.com/charmbracelet/x/ansi v0.11.813+ github.com/charmbracelet/x/term v0.2.21314 github.com/cockroachdb/apd/v3 v3.2.31415 github.com/parquet-go/parquet-go v0.32.01516 github.com/sahilm/fuzzy v0.1.31617 github.com/xuri/excelize/v2 v2.11.01718 golang.org/x/crypto v0.57.019+ golang.org/x/sys v0.48.01820 golang.org/x/text v0.42.01921 modernc.org/sqlite v1.59.02022 )@@ -26,7 +28,6 @@ require (
2628 github.com/charmbracelet/colorprofile v0.4.3 // indirect2729 github.com/charmbracelet/keygen v0.5.4 // indirect2830 github.com/charmbracelet/x/conpty v0.2.0 // indirect29- github.com/charmbracelet/x/term v0.2.2 // indirect3031 github.com/charmbracelet/x/termios v0.1.1 // indirect3132 github.com/charmbracelet/x/windows v0.2.2 // indirect3233 github.com/charmbracelet/x/xpty v0.1.4 // indirect@@ -58,7 +59,6 @@ require (
5859 golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect5960 golang.org/x/net v0.59.0 // indirect6061 golang.org/x/sync v0.23.0 // indirect61- golang.org/x/sys v0.48.0 // indirect6262 google.golang.org/protobuf v1.34.2 // indirect6363 modernc.org/libc v1.75.7 // indirect6464 modernc.org/mathutil v1.7.1 // indirect@@ -0,0 +1,343 @@
1+package config2+3+import (4+ "bufio"5+ "errors"6+ "fmt"7+ "io"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "strconv"12+ "strings"13+ "time"14+)15+16+// SourceKind says where a value came from.17+type SourceKind int18+19+const (20+ FromDefault SourceKind = iota21+ FromFile22+ FromEnv23+ FromFlag24+)25+26+// Source is where a value came from: the default, a line of a config27+// file, an environment variable or a flag.28+type Source struct {29+ Kind SourceKind30+ Name string // the file, variable or flag31+ Line int // for files32+}33+34+func (s Source) String() string {35+ switch s.Kind {36+ case FromFile:37+ return fmt.Sprintf("%s:%d", s.Name, s.Line)38+ case FromEnv:39+ return "$" + s.Name40+ case FromFlag:41+ return s.Name42+ }43+ return "default"44+}45+46+// Value is one setting's value and where it came from.47+type Value struct {48+ Raw string49+ Src Source50+}51+52+// Warning is a problem with the config that didn't stop 012: an unknown53+// key, a bad value, a missing include. The option keeps its default.54+type Warning struct {55+ Src Source56+ Msg string57+}58+59+func (w Warning) String() string {60+ if w.Src.Kind == FromDefault {61+ return w.Msg62+ }63+ return w.Src.String() + ": " + w.Msg64+}65+66+// Short is the warning with a file in the config directory named67+// relative to it ("config:3: ..."), to fit on the context line.68+func (w Warning) Short() string {69+ if w.Src.Kind == FromFile {70+ if dir, err := Dir(); err == nil {71+ if rel, err := filepath.Rel(dir, w.Src.Name); err == nil && !strings.HasPrefix(rel, "..") {72+ s := w.Src73+ s.Name = rel74+ return Warning{Src: s, Msg: w.Msg}.String()75+ }76+ }77+ }78+ return w.String()79+}80+81+// Config is the effective configuration.82+type Config struct {83+ // Path is the main config file, which may not exist.84+ Path string85+ Warnings []Warning86+ vals map[string][]Value // file values, then env, then flags override87+}88+89+// Dir is 012's config directory: $XDG_CONFIG_HOME/012 when that's set90+// (on every OS, so tests and dotfile setups can point it anywhere),91+// otherwise os.UserConfigDir()/012 (~/.config/012 on Linux,92+// ~/Library/Application Support/012 on macOS, %AppData%\012 on Windows).93+func Dir() (string, error) {94+ if x := os.Getenv("XDG_CONFIG_HOME"); x != "" && filepath.IsAbs(x) {95+ return filepath.Join(x, "012"), nil96+ }97+ d, err := os.UserConfigDir()98+ if err != nil {99+ return "", err100+ }101+ return filepath.Join(d, "012"), nil102+}103+104+// DefaultPath is the main config file, <Dir>/config.105+func DefaultPath() (string, error) {106+ d, err := Dir()107+ if err != nil {108+ return "", err109+ }110+ return filepath.Join(d, "config"), nil111+}112+113+// ThemesDir is where theme files go, <Dir>/themes.114+func ThemesDir() string {115+ d, err := Dir()116+ if err != nil {117+ return ""118+ }119+ return filepath.Join(d, "themes")120+}121+122+// Load reads the config file at path (a missing file is fine), then123+// applies environment variables (from getenv) and flags, which win in124+// that order: flags > environment > config file > defaults.125+func Load(path string, getenv func(string) string, flags map[string]string) *Config {126+ c := &Config{Path: path, vals: map[string][]Value{}}127+ if path != "" {128+ c.readFile(path, false, map[string]bool{})129+ }130+ if getenv != nil {131+ for i := range Options {132+ o := &Options[i]133+ for _, env := range o.Env {134+ if v := strings.TrimSpace(getenv(env)); v != "" {135+ c.set(o, v, Source{Kind: FromEnv, Name: env})136+ break137+ }138+ }139+ }140+ }141+ for name, v := range flags {142+ if o, ok := byName[name]; ok {143+ c.set(o, v, Source{Kind: FromFlag, Name: o.Flag})144+ }145+ }146+ return c147+}148+149+// readFile reads one file and then the files it includes. seen holds the150+// files being read, to stop include cycles.151+func (c *Config) readFile(path string, optional bool, seen map[string]bool) {152+ abs, err := filepath.Abs(path)153+ if err == nil {154+ path = abs155+ }156+ if seen[path] {157+ c.warn(Source{}, "config-file "+path+" includes itself; skipped")158+ return159+ }160+ f, err := os.Open(path)161+ switch {162+ case errors.Is(err, fs.ErrNotExist):163+ if !optional && len(seen) > 0 {164+ c.warn(Source{}, "config-file "+path+" doesn't exist")165+ }166+ return167+ case err != nil:168+ c.warn(Source{}, err.Error())169+ return170+ }171+ defer f.Close()172+ seen[path] = true173+ defer delete(seen, path)174+ // Includes are read after the whole file, as in Ghostty, so they175+ // override what comes before and after them.176+ for _, inc := range c.parse(f, path) {177+ c.readFile(inc.path, inc.optional, seen)178+ }179+}180+181+type include struct {182+ path string183+ optional bool184+}185+186+// parse applies a file's lines and returns its includes.187+func (c *Config) parse(r io.Reader, path string) []include {188+ var incs []include189+ sc := bufio.NewScanner(r)190+ for n := 1; sc.Scan(); n++ {191+ src := Source{Kind: FromFile, Name: path, Line: n}192+ key, val, ok := splitLine(sc.Text())193+ if !ok {194+ if strings.TrimSpace(sc.Text()) != "" && !strings.HasPrefix(strings.TrimSpace(sc.Text()), "#") {195+ c.warn(src, fmt.Sprintf("expected key = value, got %q", strings.TrimSpace(sc.Text())))196+ }197+ continue198+ }199+ o, known := byName[key]200+ if !known {201+ c.warn(src, "unknown key "+strconv.Quote(key))202+ continue203+ }204+ if o.Name == "config-file" && val != "" {205+ inc := include{path: val}206+ if strings.HasPrefix(val, "?") {207+ inc = include{path: strings.TrimPrefix(val, "?"), optional: true}208+ }209+ inc.path = expandPath(inc.path, filepath.Dir(path))210+ incs = append(incs, inc)211+ }212+ c.set(o, val, src)213+ }214+ return incs215+}216+217+// splitLine splits "key = value", ignoring blank lines and # comments. A218+// value may be quoted to keep spaces at its ends or a #.219+func splitLine(line string) (key, val string, ok bool) {220+ line = strings.TrimSpace(line)221+ if line == "" || strings.HasPrefix(line, "#") {222+ return "", "", false223+ }224+ key, val, ok = strings.Cut(line, "=")225+ if !ok {226+ return "", "", false227+ }228+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)229+ if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {230+ if u, err := strconv.Unquote(val); err == nil {231+ val = u232+ }233+ }234+ return key, val, key != ""235+}236+237+// set records a value after validating it. An empty value resets the238+// option (for repeatable ones, clears the list).239+func (c *Config) set(o *Option, v string, src Source) {240+ if err := o.validate(v); err != nil {241+ c.warn(src, o.Name+": "+err.Error())242+ return243+ }244+ switch {245+ case v == "":246+ delete(c.vals, o.Name)247+ case o.Repeat && src.Kind == FromFile:248+ c.vals[o.Name] = append(c.vals[o.Name], Value{Raw: v, Src: src})249+ default:250+ c.vals[o.Name] = []Value{{Raw: v, Src: src}}251+ }252+}253+254+// Override sets an option for the rest of the session, over every other255+// source, e.g. a theme picked in Settings. why says who set it. An256+// invalid value is ignored and returned as an error.257+func (c *Config) Override(name, value, why string) error {258+ o := mustLookup(name)259+ if err := o.validate(value); err != nil {260+ return err261+ }262+ c.vals[name] = []Value{{Raw: value, Src: Source{Kind: FromFlag, Name: why}}}263+ return nil264+}265+266+func (c *Config) warn(src Source, msg string) {267+ c.Warnings = append(c.Warnings, Warning{Src: src, Msg: msg})268+}269+270+// Get returns an option's value and where it came from.271+func (c *Config) Get(name string) Value {272+ o := mustLookup(name)273+ if c != nil {274+ if vs := c.vals[name]; len(vs) > 0 {275+ return vs[len(vs)-1]276+ }277+ }278+ return Value{Raw: o.Default}279+}280+281+// String returns an option's value, with ~ expanded for paths.282+func (c *Config) String(name string) string {283+ v := c.Get(name)284+ if byName[name].Kind == Path && v.Raw != "" {285+ return expandPath(v.Raw, "")286+ }287+ return v.Raw288+}289+290+// Bool returns a true-or-false option.291+func (c *Config) Bool(name string) bool {292+ b, _ := strconv.ParseBool(c.Get(name).Raw)293+ return b294+}295+296+// Int returns a number option.297+func (c *Config) Int(name string) int {298+ n, _ := strconv.Atoi(c.Get(name).Raw)299+ return n300+}301+302+// Duration returns a duration option.303+func (c *Config) Duration(name string) time.Duration {304+ d, _ := time.ParseDuration(c.Get(name).Raw)305+ return d306+}307+308+// List returns every value of a repeatable option.309+func (c *Config) List(name string) []Value {310+ mustLookup(name)311+ if c == nil {312+ return nil313+ }314+ return c.vals[name]315+}316+317+// Theme returns the theme choice.318+func (c *Config) Theme() ThemeChoice {319+ t, _ := ParseThemeChoice(c.Get("theme").Raw)320+ return t321+}322+323+func mustLookup(name string) *Option {324+ o, ok := byName[name]325+ if !ok {326+ panic("config: no option " + name)327+ }328+ return o329+}330+331+// expandPath expands a leading ~ and makes a relative path relative to332+// dir, when dir is given.333+func expandPath(p, dir string) string {334+ if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {335+ if home, err := os.UserHomeDir(); err == nil {336+ p = filepath.Join(home, p[1:])337+ }338+ }339+ if dir != "" && !filepath.IsAbs(p) {340+ p = filepath.Join(dir, p)341+ }342+ return p343+}@@ -0,0 +1,333 @@
1+package config2+3+import (4+ "flag"5+ "os"6+ "path/filepath"7+ "slices"8+ "strings"9+ "testing"10+)11+12+func write(t *testing.T, path, text string) string {13+ t.Helper()14+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {15+ t.Fatal(err)16+ }17+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {18+ t.Fatal(err)19+ }20+ return path21+}22+23+func noEnv(string) string { return "" }24+25+func warnings(c *Config) string {26+ var out []string27+ for _, w := range c.Warnings {28+ out = append(out, w.String())29+ }30+ return strings.Join(out, "\n")31+}32+33+func TestParseFile(t *testing.T) {34+ dir := t.TempDir()35+ path := write(t, filepath.Join(dir, "config"), `# a comment36+theme = Dracula37+ # indented comment38+39+chart-images=false40+jev-model = " spaced "41+bogus = 142+not a setting43+log-level = loud44+`)45+ c := Load(path, noEnv, nil)46+ if got := c.String("theme"); got != "Dracula" {47+ t.Errorf("theme %q", got)48+ }49+ if c.Bool("chart-images") {50+ t.Error("chart-images should be false")51+ }52+ if got := c.String("jev-model"); got != " spaced " {53+ t.Errorf("quoted value %q", got)54+ }55+ if v := c.Get("log-level"); v.Raw != "info" || v.Src.Kind != FromDefault {56+ t.Errorf("an invalid value keeps the default: %+v", v)57+ }58+ w := warnings(c)59+ for _, want := range []string{`config:7: unknown key "bogus"`, `config:8: expected key = value`, `config:9: log-level: "loud" isn't one of`} {60+ if !strings.Contains(w, want) {61+ t.Errorf("warnings lack %q:\n%s", want, w)62+ }63+ }64+ if v := c.Get("theme"); v.Src.Kind != FromFile || v.Src.Line != 2 {65+ t.Errorf("theme source %+v", v.Src)66+ }67+}68+69+func TestMissingFileIsFine(t *testing.T) {70+ c := Load(filepath.Join(t.TempDir(), "config"), noEnv, nil)71+ if len(c.Warnings) != 0 || c.String("theme") != "terminal" || !c.Bool("chart-images") {72+ t.Errorf("defaults: %v %q", c.Warnings, c.String("theme"))73+ }74+}75+76+func TestIncludes(t *testing.T) {77+ dir := t.TempDir()78+ write(t, filepath.Join(dir, "themes.conf"), "theme = Nord\n")79+ write(t, filepath.Join(dir, "sub", "more"), "jev-model = included\nconfig-file = ../config\n")80+ path := write(t, filepath.Join(dir, "config"), `config-file = themes.conf81+theme = before-include-wins-not82+config-file = ?optional-missing83+config-file = required-missing84+config-file = sub/more85+`)86+ c := Load(path, noEnv, nil)87+ if got := c.String("theme"); got != "Nord" {88+ t.Errorf("includes are read after the file, so they win: %q", got)89+ }90+ if got := c.String("jev-model"); got != "included" {91+ t.Errorf("nested include: %q", got)92+ }93+ w := warnings(c)94+ if strings.Contains(w, "optional-missing") {95+ t.Errorf("an optional include warned:\n%s", w)96+ }97+ if !strings.Contains(w, "required-missing doesn't exist") {98+ t.Errorf("no warning for a missing include:\n%s", w)99+ }100+ if !strings.Contains(w, "includes itself") {101+ t.Errorf("no warning for the cycle:\n%s", w)102+ }103+ if n := len(c.List("config-file")); n != 5 {104+ t.Errorf("config-file is repeatable: %d values", n)105+ }106+}107+108+func TestSelfInclude(t *testing.T) {109+ dir := t.TempDir()110+ path := write(t, filepath.Join(dir, "config"), "config-file = config\ntheme = x\n")111+ c := Load(path, noEnv, nil)112+ if c.String("theme") != "x" || !strings.Contains(warnings(c), "includes itself") {113+ t.Errorf("self include: %q %v", c.String("theme"), c.Warnings)114+ }115+}116+117+func TestPrecedence(t *testing.T) {118+ path := write(t, filepath.Join(t.TempDir(), "config"), "log-file = from-file.jsonl\njev-model = file-model\ntheme = Nord\n")119+ env := map[string]string{"O12_LOG": "from-env.jsonl", "TYPESAFE_DEFAULT_MODEL": "env-model"}120+ c := Load(path, func(k string) string { return env[k] }, map[string]string{"log-file": "from-flag.jsonl"})121+ if got := c.Get("log-file"); got.Raw != "from-flag.jsonl" || got.Src.String() != "--log" {122+ t.Errorf("flags win: %+v", got)123+ }124+ if got := c.Get("jev-model"); got.Raw != "env-model" || got.Src.String() != "$TYPESAFE_DEFAULT_MODEL" {125+ t.Errorf("environment beats the file: %+v", got)126+ }127+ if got := c.Get("theme"); got.Raw != "Nord" {128+ t.Errorf("file beats the default: %+v", got)129+ }130+ if got := c.Get("log-level"); got.Raw != "info" || got.Src.String() != "default" {131+ t.Errorf("default: %+v", got)132+ }133+}134+135+func TestEmptyValueResets(t *testing.T) {136+ path := write(t, filepath.Join(t.TempDir(), "config"), "theme = Nord\ntheme =\n")137+ if got := Load(path, noEnv, nil).String("theme"); got != "terminal" {138+ t.Errorf("empty value should reset: %q", got)139+ }140+}141+142+func TestParseFlags(t *testing.T) {143+ flags, rest, err := ParseFlags([]string{"--log", "a.jsonl", "--otlp=http://localhost:4318", "b.csv", "--theme", "Nord"})144+ if err != nil || flags["log-file"] != "a.jsonl" || flags["otlp-endpoint"] != "http://localhost:4318" ||145+ flags["theme"] != "Nord" || !slices.Equal(rest, []string{"b.csv"}) {146+ t.Errorf("got %v %v %v", flags, rest, err)147+ }148+ if _, _, err := ParseFlags([]string{"--otlp"}); err == nil {149+ t.Error("--otlp without a value")150+ }151+}152+153+func TestBaseURL(t *testing.T) {154+ for v, ok := range map[string]bool{155+ "https://api.example.com": true,156+ "http://127.0.0.1:8799": true,157+ "http://localhost:1234/v1": true,158+ "http://[::1]:80": true,159+ "http://api.example.com": false,160+ "http://127.evil.com": false,161+ "ftp://localhost": false,162+ "not a url": false,163+ "https://": false,164+ "http://localhost.evil.com": false,165+ } {166+ if err := CheckBaseURL(v); (err == nil) != ok {167+ t.Errorf("%s: %v", v, err)168+ }169+ }170+ path := write(t, filepath.Join(t.TempDir(), "config"), "jev-base-url = http://attacker.example\n")171+ c := Load(path, noEnv, nil)172+ if c.String("jev-base-url") != "" || !strings.Contains(warnings(c), "must be https") {173+ t.Errorf("an http base URL was accepted: %q %v", c.String("jev-base-url"), c.Warnings)174+ }175+ env := func(k string) string {176+ if k == "TYPESAFE_BASE_URL" {177+ return "http://attacker.example"178+ }179+ return ""180+ }181+ if c := Load("", env, nil); c.String("jev-base-url") != "" {182+ t.Error("an http base URL from the environment was accepted")183+ }184+}185+186+func TestThemeChoice(t *testing.T) {187+ for v, want := range map[string]ThemeChoice{188+ "": {"terminal", "terminal"},189+ "Nord": {"Nord", "Nord"},190+ "light:Nord Light,dark:Nord": {"Nord Light", "Nord"},191+ "dark: B , light: A": {"A", "B"},192+ } {193+ got, err := ParseThemeChoice(v)194+ if err != nil || got != want {195+ t.Errorf("%q: %+v %v", v, got, err)196+ }197+ }198+ for _, bad := range []string{"light:A", "light:A,dim:B", "light:,dark:B"} {199+ if _, err := ParseThemeChoice(bad); err == nil {200+ t.Errorf("%q accepted", bad)201+ }202+ }203+}204+205+func TestSplitCommand(t *testing.T) {206+ for in, want := range map[string][]string{207+ `op read op://Private/TypeSafe/credential`: {"op", "read", "op://Private/TypeSafe/credential"},208+ `pass show "api keys/typesafe"`: {"pass", "show", "api keys/typesafe"},209+ `sh -c 'pass show x | head -1'`: {"sh", "-c", "pass show x | head -1"},210+ `echo a\ b $HOME`: {"echo", "a b", "$HOME"},211+ `printf ''`: {"printf", ""},212+ } {213+ got, err := SplitCommand(in)214+ if err != nil || !slices.Equal(got, want) {215+ t.Errorf("%s: %q %v", in, got, err)216+ }217+ }218+ for _, bad := range []string{`echo 'open`, ``, ` `} {219+ if _, err := SplitCommand(bad); err == nil {220+ t.Errorf("%q accepted", bad)221+ }222+ }223+}224+225+func TestShowRedacts(t *testing.T) {226+ path := write(t, filepath.Join(t.TempDir(), "config"), "otlp-endpoint = https://user:hunter2@collector.example/?token=abc\n")227+ out := Load(path, noEnv, nil).Show()228+ if strings.Contains(out, "hunter2") || strings.Contains(out, "token=abc") {229+ t.Errorf("secret shown:\n%s", out)230+ }231+ if !strings.Contains(out, "otlp-endpoint = https://xxxxx@collector.example/?xxxxx") {232+ t.Errorf("redacted URL missing:\n%s", out)233+ }234+}235+236+func TestDefaultFileParses(t *testing.T) {237+ path := write(t, filepath.Join(t.TempDir(), "config"), DefaultFile())238+ c := Load(path, noEnv, nil)239+ if len(c.Warnings) != 0 {240+ t.Errorf("the default file warns: %v", c.Warnings)241+ }242+ for _, o := range Options {243+ if !strings.Contains(DefaultFile(), "# "+o.Name+" = ") {244+ t.Errorf("default file lacks %s", o.Name)245+ }246+ }247+}248+249+func TestSetInFile(t *testing.T) {250+ path := filepath.Join(t.TempDir(), "sub", "config")251+ if err := SetInFile(path, "theme", "Nord"); err != nil {252+ t.Fatal(err)253+ }254+ write(t, path, "# mine\ntheme = A\nchart-images = false\ntheme = B # later wins\n")255+ if err := SetInFile(path, "theme", "Dracula"); err != nil {256+ t.Fatal(err)257+ }258+ data, _ := os.ReadFile(path)259+ if string(data) != "# mine\ntheme = A\nchart-images = false\ntheme = Dracula\n" {260+ t.Errorf("got %q", data)261+ }262+ if err := SetInFile(path, "nope", "x"); err == nil {263+ t.Error("unknown key accepted")264+ }265+}266+267+func TestRegistry(t *testing.T) {268+ seen := map[string]bool{}269+ for _, o := range Options {270+ if seen[o.Name] {271+ t.Errorf("%s registered twice", o.Name)272+ }273+ seen[o.Name] = true274+ if o.Desc == "" || !slices.Contains(Groups, o.Group) {275+ t.Errorf("%s: needs a description and a known group", o.Name)276+ }277+ if err := o.validate(o.Default); err != nil {278+ t.Errorf("%s: default %q invalid: %v", o.Name, o.Default, err)279+ }280+ }281+}282+283+func TestServeOptions(t *testing.T) {284+ path := write(t, filepath.Join(t.TempDir(), "config"), "serve-max-sessions = 3\nserve-idle-timeout = 1h\nserve-listen = nope\nserve-max-sessions = 0\n")285+ c := Load(path, noEnv, nil)286+ if c.Int("serve-max-sessions") != 3 || c.Duration("serve-idle-timeout").Minutes() != 60 || c.String("serve-listen") != "127.0.0.1:2312" {287+ t.Errorf("serve: %d %v %q", c.Int("serve-max-sessions"), c.Duration("serve-idle-timeout"), c.String("serve-listen"))288+ }289+ if w := warnings(c); !strings.Contains(w, "isn't host:port") || !strings.Contains(w, "at least 1") {290+ t.Errorf("warnings:\n%s", w)291+ }292+ if home, _ := os.UserHomeDir(); c.String("serve-authorized-keys") != filepath.Join(home, ".ssh", "authorized_keys") {293+ t.Errorf("~ not expanded: %q", c.String("serve-authorized-keys"))294+ }295+}296+297+func TestDir(t *testing.T) {298+ x := t.TempDir()299+ t.Setenv("XDG_CONFIG_HOME", x)300+ if d, _ := Dir(); d != filepath.Join(x, "012") {301+ t.Errorf("XDG_CONFIG_HOME ignored: %s", d)302+ }303+ if p, _ := DefaultPath(); p != filepath.Join(x, "012", "config") {304+ t.Errorf("path %s", p)305+ }306+}307+308+var updateDocs = flag.Bool("update-docs", false, "rewrite the reference in docs/config.md")309+310+// TestConfigDoc keeps docs/config.md's reference in step with the311+// registry: run `go test ./internal/config -update-docs` after changing312+// an option.313+func TestConfigDoc(t *testing.T) {314+ const path = "../../docs/config.md"315+ data, err := os.ReadFile(path)316+ if err != nil {317+ t.Fatal(err)318+ }319+ intro, _, ok := strings.Cut(string(data), DocsMarker)320+ if !ok {321+ t.Fatalf("%s lacks the generated-reference marker", path)322+ }323+ want := intro + DocsMarker + "\n" + Reference()324+ if *updateDocs {325+ if err := os.WriteFile(path, []byte(want), 0o644); err != nil {326+ t.Fatal(err)327+ }328+ return329+ }330+ if string(data) != want {331+ t.Errorf("%s is stale: run go test ./internal/config -update-docs", path)332+ }333+}@@ -0,0 +1,53 @@
1+package config2+3+import (4+ "fmt"5+ "strings"6+)7+8+// DocsMarker divides docs/config.md: the introduction above it is9+// written by hand, the reference below it is Reference's output.10+const DocsMarker = "<!-- Generated from internal/config/registry.go by `go test ./internal/config -update-docs`. Don't edit below. -->"11+12+// Reference is the option reference in Markdown, one section per group.13+func Reference() string {14+ var b strings.Builder15+ for _, g := range Groups {16+ fmt.Fprintf(&b, "\n### %s\n", g)17+ for _, o := range Options {18+ if o.Group != g {19+ continue20+ }21+ fmt.Fprintf(&b, "\n#### `%s`\n\n%s\n\n", o.Name, o.Desc)22+ b.WriteString("| | |\n|---|---|\n")23+ fmt.Fprintf(&b, "| Type | %s |\n", o.typeDoc())24+ def := "(empty)"25+ if o.Default != "" {26+ def = "`" + o.Default + "`"27+ }28+ fmt.Fprintf(&b, "| Default | %s |\n", def)29+ if len(o.Env) > 0 {30+ fmt.Fprintf(&b, "| Environment | `%s` |\n", strings.Join(o.Env, "`, `"))31+ }32+ if o.Flag != "" {33+ fmt.Fprintf(&b, "| Flag | `%s` |\n", o.Flag)34+ }35+ if o.Repeat {36+ b.WriteString("| Repeatable | yes |\n")37+ }38+ reload := "restart 012"39+ if o.Live {40+ reload = "File > Settings > Reload config"41+ }42+ fmt.Fprintf(&b, "| Applies | %s |\n", reload)43+ }44+ }45+ return b.String()46+}47+48+func (o *Option) typeDoc() string {49+ if o.Kind == Enum {50+ return "one of `" + strings.Join(o.Values, "`, `") + "`"51+ }52+ return o.Kind.String()53+}@@ -0,0 +1,56 @@
1+package config2+3+import (4+ "errors"5+ "io/fs"6+ "os"7+ "os/exec"8+ "path/filepath"9+ "runtime"10+ "strings"11+)12+13+// Editor returns the command that opens path in the user's editor:14+// $VISUAL, then $EDITOR (either may carry arguments, e.g. "code -w"),15+// else vi, or notepad on Windows.16+func Editor(path string, getenv func(string) string) (*exec.Cmd, error) {17+ ed := getenv("VISUAL")18+ if ed == "" {19+ ed = getenv("EDITOR")20+ }21+ if ed == "" {22+ ed = "vi"23+ if runtime.GOOS == "windows" {24+ ed = "notepad"25+ }26+ }27+ argv, err := SplitCommand(ed)28+ if err != nil {29+ return nil, err30+ }31+ return exec.Command(argv[0], append(argv[1:], path)...), nil32+}33+34+// EnsureFile creates the config file at path, with every option35+// commented out at its default, when it doesn't exist yet.36+func EnsureFile(path string) error {37+ if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {38+ return err39+ }40+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {41+ return err42+ }43+ return os.WriteFile(path, []byte(DefaultFile()), 0o600)44+}45+46+// Tilde shortens a path in the home directory to ~/..., for messages.47+func Tilde(path string) string {48+ home, err := os.UserHomeDir()49+ if err != nil || home == "" {50+ return path51+ }52+ if rel, err := filepath.Rel(home, path); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {53+ return "~" + string(filepath.Separator) + rel54+ }55+ return path56+}@@ -0,0 +1,186 @@
1+package config2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+)11+12+// ParseFlags takes the options that have flags ("--log path" or13+// "--log=path") out of args, returning their values by option name and14+// the arguments left.15+func ParseFlags(args []string) (map[string]string, []string, error) {16+ flags := map[string]string{}17+ var rest []string18+ for i := 0; i < len(args); i++ {19+ name, value, hasValue := strings.Cut(args[i], "=")20+ o := flagOption(name)21+ switch {22+ case o == nil:23+ rest = append(rest, args[i])24+ case hasValue:25+ flags[o.Name] = value26+ case i+1 < len(args):27+ flags[o.Name] = args[i+1]28+ i++29+ default:30+ return nil, nil, errors.New(name + " needs a value")31+ }32+ }33+ return flags, rest, nil34+}35+36+func flagOption(flag string) *Option {37+ if !strings.HasPrefix(flag, "--") {38+ return nil39+ }40+ for i := range Options {41+ if Options[i].Flag == flag {42+ return &Options[i]43+ }44+ }45+ return nil46+}47+48+// FlagUsage lists the flags for a usage line, e.g. "[--log file]".49+func FlagUsage() string {50+ var parts []string51+ for _, o := range Options {52+ if o.Flag != "" {53+ parts = append(parts, "["+o.Flag+" "+strings.TrimPrefix(o.Flag, "--")+"]")54+ }55+ }56+ return strings.Join(parts, " ")57+}58+59+// Show writes the effective configuration: every option with its value60+// and where it came from, values that can hold secrets redacted.61+func (c *Config) Show() string {62+ var b strings.Builder63+ fmt.Fprintf(&b, "# %s\n", c.Path)64+ for _, g := range Groups {65+ fmt.Fprintf(&b, "\n# %s\n", g)66+ for _, o := range Options {67+ if o.Group != g {68+ continue69+ }70+ vals := c.List(o.Name)71+ if !o.Repeat || len(vals) == 0 {72+ vals = []Value{c.Get(o.Name)}73+ }74+ for _, v := range vals {75+ raw := v.Raw76+ if o.Redact != nil && raw != "" {77+ raw = o.Redact(raw)78+ }79+ fmt.Fprintf(&b, "%s = %s", o.Name, raw)80+ b.WriteString(strings.Repeat(" ", max(1, 40-len(o.Name)-len(raw)-3)))81+ fmt.Fprintf(&b, "# %s\n", v.Src)82+ }83+ }84+ }85+ for _, w := range c.Warnings {86+ fmt.Fprintf(&b, "\n# warning: %s", w)87+ }88+ if len(c.Warnings) > 0 {89+ b.WriteByte('\n')90+ }91+ return b.String()92+}93+94+// DefaultFile is a config file with every option commented out at its95+// default, each with its description: what `012 config default` prints96+// and `012 config edit` starts a new file with.97+func DefaultFile() string {98+ var b strings.Builder99+ b.WriteString("# 012 configuration. Lines are key = value; # starts a comment.\n")100+ b.WriteString("# Flags and environment variables override this file.\n")101+ b.WriteString("# See `012 config` for the values in effect and docs/config.md for more.\n")102+ for _, g := range Groups {103+ fmt.Fprintf(&b, "\n# ---- %s ----\n", g)104+ for _, o := range Options {105+ if o.Group != g {106+ continue107+ }108+ b.WriteString("\n")109+ for _, l := range wrap(stripTicks(o.Desc), 74) {110+ b.WriteString("# " + l + "\n")111+ }112+ fmt.Fprintf(&b, "# %s = %s\n", o.Name, o.Default)113+ }114+ }115+ return b.String()116+}117+118+func stripTicks(s string) string { return strings.ReplaceAll(s, "`", "") }119+120+// wrap breaks s into lines of at most w columns at spaces.121+func wrap(s string, w int) []string {122+ var lines []string123+ line := ""124+ for word := range strings.FieldsSeq(s) {125+ if line != "" && len(line)+1+len(word) > w {126+ lines = append(lines, line)127+ line = ""128+ }129+ if line != "" {130+ line += " "131+ }132+ line += word133+ }134+ if line != "" {135+ lines = append(lines, line)136+ }137+ return lines138+}139+140+// SetInFile sets key to value in the config file at path: the last line141+// setting key is replaced, or a line is added at the end. Comments and142+// every other line are kept. The file and its directory are created if143+// needed.144+func SetInFile(path, key, value string) error {145+ if _, ok := byName[key]; !ok {146+ return errors.New("no option " + key)147+ }148+ data, err := os.ReadFile(path)149+ if err != nil && !errors.Is(err, os.ErrNotExist) {150+ return err151+ }152+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")153+ if len(data) == 0 {154+ lines = nil155+ }156+ newLine := key + " = " + value157+ at := -1158+ for i, l := range lines {159+ if k, _, ok := splitLine(l); ok && k == key {160+ at = i161+ }162+ }163+ if at >= 0 {164+ lines[at] = newLine165+ } else {166+ lines = append(lines, newLine)167+ }168+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {169+ return err170+ }171+ tmp := path + ".tmp"172+ if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {173+ return err174+ }175+ return os.Rename(tmp, path)176+}177+178+// Names is every option name, sorted.179+func Names() []string {180+ names := make([]string, len(Options))181+ for i, o := range Options {182+ names[i] = o.Name183+ }184+ slices.Sort(names)185+ return names186+}@@ -0,0 +1,347 @@
1+// Package config is 012's settings file: a Ghostty-style list of2+// "key = value" lines in <user config dir>/012/config, with # comments and3+// config-file includes. One registry (Options) says what every option is4+// called, its type, default, environment variable, flag and meaning; the5+// parser, `012 config`, docs/config.md and the in-app Settings all come6+// from it.7+//8+// Settings are process-wide. Settings that belong to a workbook (decimal9+// arithmetic) stay in the workbook's file. Secrets never go here: the JEV10+// API key lives in the OS credential store (see internal/keyring).11+package config12+13+import (14+ "errors"15+ "fmt"16+ "net"17+ "net/url"18+ "strconv"19+ "strings"20+ "time"21+)22+23+// Kind is an option's type.24+type Kind int25+26+const (27+ String Kind = iota28+ Bool29+ Enum // one of Option.Values30+ Path // a file path; ~ is the home directory31+ URL32+ Command // a program and its arguments, run without a shell33+ Int // a whole number, at least 134+ Duration // e.g. 30m or 1h30m; 0 for never35+ Address // host:port36+)37+38+func (k Kind) String() string {39+ return [...]string{"text", "true or false", "one of", "path", "URL", "command", "number", "duration", "host:port"}[k]40+}41+42+// Option is one setting.43+type Option struct {44+ Name string45+ Kind Kind46+ Group string // heading in docs/config.md and `012 config`47+ Default string // as it would be written in the file48+ Values []string // for Enum49+ Env []string // environment variables that set it, first set wins50+ Flag string // command-line flag, e.g. "--log"51+ Repeat bool // may be given more than once; each adds a value52+ Live bool // Reload config applies it without restarting53+ Desc string // what it does, one or more sentences54+ // Check validates a value beyond its kind. Invalid values are55+ // reported as warnings and the option keeps its previous value.56+ Check func(string) error57+ // Redact shows a value safely in `012 config`, e.g. without a URL's58+ // password.59+ Redact func(string) string60+}61+62+// Group names, in the order docs list them.63+const (64+ GroupAppearance = "Appearance"65+ GroupJEV = "JEV functions"66+ GroupTelemetry = "Telemetry"67+ GroupServe = "012 serve"68+ GroupFiles = "Config files"69+)70+71+// Groups is the order groups are listed in.72+var Groups = []string{GroupAppearance, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}73+74+// Options is every setting. Add an option here and read it with75+// Config.String, Bool or List; parsing, `012 config`, docs/config.md76+// (go test ./internal/config -update-docs) and Settings follow.77+var Options = []Option{78+ {Name: "theme", Group: GroupAppearance, Default: "terminal", Env: []string{"O12_THEME"}, Flag: "--theme", Live: true,79+ Desc: "Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a " +80+ "terminal color scheme, built in (`012 config themes` lists them) or a file in the themes " +81+ "directory, drawn in its own colors with solid menu and status bars. " +82+ "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",83+ Check: checkTheme},84+ {Name: "chart-images", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_CHART_IMAGES"}, Live: true,85+ Desc: "Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). " +86+ "When false, charts are always text."},87+ {Name: "notifications", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_NOTIFICATIONS"}, Live: true,88+ Desc: "Send a desktop notification (OSC 9) when JEV answers or an import finishes while the " +89+ "terminal window is in the background."},90+ {Name: "keymap", Kind: Enum, Group: GroupAppearance, Default: "default", Values: []string{"default", "vim"}, Env: []string{"O12_KEYMAP"}, Live: true,91+ Desc: "Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, " +92+ "visual selection and a : command line (File > Settings > Vim keys)."},93+94+ {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,95+ Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +96+ "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +97+ "`pass show typesafe`. It runs the first time a sheet asks JEV something, without a shell, for up to 10 seconds, and its first line of output is the key; for pipes, write " +98+ "`sh -c '...'` yourself. The key itself never goes in this file."},99+ {Name: "jev-credential-store", Kind: Bool, Group: GroupJEV, Default: "true", Env: []string{"O12_JEV_CREDENTIAL_STORE"},100+ Desc: "Look for the API key in the OS credential store (macOS Keychain, Windows Credential Manager, " +101+ "the Secret Service on Linux), where `012 config set-key` and Settings put it."},102+ {Name: "jev-base-url", Kind: URL, Group: GroupJEV, Env: []string{"TYPESAFE_BASE_URL"},103+ Desc: "The TypeSafe service to ask; the SDK's default when empty. Must be https, except on localhost.",104+ Check: CheckBaseURL, Redact: redactURL},105+ {Name: "jev-model", Group: GroupJEV, Env: []string{"TYPESAFE_DEFAULT_MODEL"},106+ Desc: "The JEV model to ask; the service's default when empty."},107+108+ {Name: "log-file", Kind: Path, Group: GroupTelemetry, Env: []string{"O12_LOG"}, Flag: "--log",109+ Desc: "Append telemetry events to this JSON log file. See docs/observability.md."},110+ {Name: "log-level", Kind: Enum, Group: GroupTelemetry, Default: "info", Values: []string{"debug", "info", "warn", "error"},111+ Env: []string{"O12_LOG_LEVEL"},112+ Desc: "The least severe telemetry event recorded; debug adds every frame and command."},113+ {Name: "otlp-endpoint", Kind: URL, Group: GroupTelemetry, Env: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Flag: "--otlp",114+ Desc: "Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. " +115+ "The other OTEL_* variables still apply.",116+ Redact: redactURL},117+118+ {Name: "serve-listen", Kind: Address, Group: GroupServe, Default: "127.0.0.1:2312",119+ Desc: "The address 012 serve listens on. Anything but the loopback address lets other machines " +120+ "reach it (with an authorized key). See docs/ssh.md."},121+ {Name: "serve-authorized-keys", Kind: Path, Group: GroupServe, Default: "~/.ssh/authorized_keys",122+ Desc: "The public keys allowed to log in to 012 serve, in OpenSSH's authorized_keys format."},123+ {Name: "serve-host-key", Kind: Path, Group: GroupServe,124+ Desc: "012 serve's private host key, generated when missing; " +125+ "ssh_host_ed25519_key in the config directory when empty."},126+ {Name: "serve-idle-timeout", Kind: Duration, Group: GroupServe, Default: "30m",127+ Desc: "End a 012 serve session that has had no input for this long; 0 never does."},128+ {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",129+ Desc: "How many 012 serve sessions may run at once; more are turned away."},130+131+ {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,132+ Desc: "Read another config file after this one, relative to this file's directory. " +133+ "A leading `?` makes it optional: no warning when it doesn't exist."},134+}135+136+// byName indexes Options.137+var byName = func() map[string]*Option {138+ m := make(map[string]*Option, len(Options))139+ for i := range Options {140+ m[Options[i].Name] = &Options[i]141+ }142+ return m143+}()144+145+// Lookup returns the option called name.146+func Lookup(name string) (*Option, bool) {147+ o, ok := byName[name]148+ return o, ok149+}150+151+// validate checks a value against the option's kind and Check.152+func (o *Option) validate(v string) error {153+ if v == "" {154+ return nil // empty resets to the default155+ }156+ switch o.Kind {157+ case Bool:158+ if _, err := strconv.ParseBool(v); err != nil {159+ return fmt.Errorf("%q isn't true or false", v)160+ }161+ case Enum:162+ if !contains(o.Values, v) {163+ return fmt.Errorf("%q isn't one of %s", v, strings.Join(o.Values, ", "))164+ }165+ case URL:166+ withScheme := v167+ if !strings.Contains(v, "://") {168+ withScheme = "http://" + v // host:port, as OTEL_EXPORTER_OTLP_ENDPOINT allows169+ }170+ u, err := url.Parse(withScheme)171+ if err != nil || u.Host == "" {172+ return fmt.Errorf("%q isn't a URL", v)173+ }174+ case Command:175+ if _, err := SplitCommand(v); err != nil {176+ return err177+ }178+ case Int:179+ if n, err := strconv.Atoi(v); err != nil || n < 1 {180+ return fmt.Errorf("%q isn't a whole number of at least 1", v)181+ }182+ case Duration:183+ if d, err := time.ParseDuration(v); err != nil || d < 0 {184+ return fmt.Errorf("%q isn't a duration like 30m or 1h", v)185+ }186+ case Address:187+ if _, _, err := net.SplitHostPort(v); err != nil {188+ return fmt.Errorf("%q isn't host:port", v)189+ }190+ }191+ if o.Check != nil {192+ return o.Check(v)193+ }194+ return nil195+}196+197+func contains(list []string, s string) bool {198+ for _, v := range list {199+ if v == s {200+ return true201+ }202+ }203+ return false204+}205+206+// CheckBaseURL accepts https URLs, and http only for this machine, so a207+// config can't send the API key anywhere in the clear.208+func CheckBaseURL(v string) error {209+ u, err := url.Parse(v)210+ if err != nil || u.Host == "" {211+ return fmt.Errorf("%q isn't a URL", v)212+ }213+ switch {214+ case u.Scheme == "https":215+ return nil216+ case u.Scheme == "http" && IsLocalhost(u.Hostname()):217+ return nil218+ }219+ return errors.New("the JEV base URL must be https (http only for localhost)")220+}221+222+// IsLocalhost reports whether host names this machine.223+func IsLocalhost(host string) bool {224+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {225+ return true226+ }227+ ip := net.ParseIP(host)228+ return ip != nil && ip.IsLoopback()229+}230+231+// checkTheme checks the shape of a theme value: a name, or light: and232+// dark: names. Whether the names exist is known only when it's loaded.233+func checkTheme(v string) error {234+ _, err := ParseThemeChoice(v)235+ return err236+}237+238+// ThemeChoice is the theme value: one theme, or one for each kind of239+// terminal background.240+type ThemeChoice struct{ Light, Dark string }241+242+// Pick returns the theme for a dark or light terminal.243+func (c ThemeChoice) Pick(dark bool) string {244+ if dark {245+ return c.Dark246+ }247+ return c.Light248+}249+250+// ParseThemeChoice parses "name" or "light:A,dark:B" (either order).251+func ParseThemeChoice(v string) (ThemeChoice, error) {252+ v = strings.TrimSpace(v)253+ if v == "" {254+ v = "terminal"255+ }256+ if !strings.Contains(v, "light:") && !strings.Contains(v, "dark:") {257+ return ThemeChoice{Light: v, Dark: v}, nil258+ }259+ var c ThemeChoice260+ for part := range strings.SplitSeq(v, ",") {261+ k, name, ok := strings.Cut(strings.TrimSpace(part), ":")262+ name = strings.TrimSpace(name)263+ switch {264+ case !ok || name == "":265+ return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)266+ case k == "light":267+ c.Light = name268+ case k == "dark":269+ c.Dark = name270+ default:271+ return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)272+ }273+ }274+ if c.Light == "" || c.Dark == "" {275+ return c, fmt.Errorf("theme %q: give both light: and dark:", v)276+ }277+ return c, nil278+}279+280+// String is the choice as written in the file.281+func (c ThemeChoice) String() string {282+ if c.Light == c.Dark {283+ return c.Light284+ }285+ return "light:" + c.Light + ",dark:" + c.Dark286+}287+288+// redactURL hides a URL's password and query, which can carry tokens.289+func redactURL(v string) string {290+ u, err := url.Parse(v)291+ if err != nil {292+ return "(hidden)"293+ }294+ if u.User != nil {295+ u.User = url.User("xxxxx")296+ }297+ if u.RawQuery != "" {298+ u.RawQuery = "xxxxx"299+ }300+ return u.String()301+}302+303+// SplitCommand splits a command line into words, as a shell would304+// without expanding anything: spaces separate words, and single or305+// double quotes keep spaces inside one (a backslash escapes the next306+// character outside single quotes). No variables, globs or pipes.307+func SplitCommand(s string) ([]string, error) {308+ var words []string309+ var cur strings.Builder310+ inWord := false311+ var quote rune312+ escaped := false313+ for _, r := range s {314+ switch {315+ case escaped:316+ cur.WriteRune(r)317+ escaped = false318+ case r == '\\' && quote != '\'':319+ escaped, inWord = true, true320+ case quote != 0 && r == quote:321+ quote = 0322+ case quote != 0:323+ cur.WriteRune(r)324+ case r == '\'' || r == '"':325+ quote, inWord = r, true326+ case r == ' ' || r == '\t':327+ if inWord {328+ words = append(words, cur.String())329+ cur.Reset()330+ inWord = false331+ }332+ default:333+ cur.WriteRune(r)334+ inWord = true335+ }336+ }337+ if quote != 0 || escaped {338+ return nil, fmt.Errorf("command %q: unclosed quote", s)339+ }340+ if inWord {341+ words = append(words, cur.String())342+ }343+ if len(words) == 0 {344+ return nil, errors.New("empty command")345+ }346+ return words, nil347+}@@ -0,0 +1,182 @@
1+package jev2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "errors"8+ "fmt"9+ "os"10+ "os/exec"11+ "path/filepath"12+ "strings"13+ "sync"14+ "time"15+16+ typesafe "github.com/FelineStateMachine/typesafe-go"17+18+ "github.com/FelineStateMachine/012/internal/config"19+ "github.com/FelineStateMachine/012/internal/keyring"20+)21+22+// The API key is never in the config file or on a command line. It comes23+// from, in order:24+//25+// 1. TYPESAFE_API_KEY in the environment;26+// 2. the OS credential store, where `012 config set-key` and File >27+// Settings > JEV API key put it;28+// 3. jev-api-key-command, a command that prints it (a password29+// manager's CLI), run without a shell.30+31+// KeyEnv is the environment variable that holds the key.32+const KeyEnv = "TYPESAFE_API_KEY"33+34+// KeyCommandTimeout bounds jev-api-key-command.35+const KeyCommandTimeout = 10 * time.Second36+37+// KeySources are the places ResolveKey looks.38+type KeySources struct {39+ Getenv func(string) string40+ Store keyring.Store // nil skips the credential store41+ Command string // jev-api-key-command; empty skips it42+ Timeout time.Duration // for Command; KeyCommandTimeout when zero43+ // run runs the command; tests replace it. It gets the program and44+ // its arguments exactly as they'd be executed.45+ run func(ctx context.Context, argv []string) ([]byte, error)46+}47+48+// ResolveKey finds the API key. from says where it came from:49+// "TYPESAFE_API_KEY", the store's name, or "jev-api-key-command". With no50+// key anywhere it returns "", "", nil. A store or command that fails is51+// skipped, and its error is returned only if no later source has a key.52+func ResolveKey(ctx context.Context, s KeySources) (key, from string, err error) {53+ if s.Getenv != nil {54+ if k := strings.TrimSpace(s.Getenv(KeyEnv)); k != "" {55+ return k, KeyEnv, nil56+ }57+ }58+ var errs []error59+ if s.Store != nil {60+ k, err := s.Store.Get(ctx)61+ switch {62+ case err == nil && strings.TrimSpace(k) != "":63+ return strings.TrimSpace(k), s.Store.Name(), nil64+ case err != nil && !errors.Is(err, keyring.ErrNotFound):65+ errs = append(errs, err)66+ }67+ }68+ if s.Command != "" {69+ k, err := s.runCommand(ctx)70+ if err == nil {71+ return k, "jev-api-key-command", nil72+ }73+ errs = append(errs, err)74+ }75+ return "", "", errors.Join(errs...)76+}77+78+// runCommand runs jev-api-key-command and returns the first line it79+// prints. The command is split into words (quotes group, nothing is80+// expanded) and run directly, not through a shell.81+func (s KeySources) runCommand(ctx context.Context) (string, error) {82+ argv, err := config.SplitCommand(s.Command)83+ if err != nil {84+ return "", fmt.Errorf("jev-api-key-command: %w", err)85+ }86+ timeout := s.Timeout87+ if timeout <= 0 {88+ timeout = KeyCommandTimeout89+ }90+ ctx, cancel := context.WithTimeout(ctx, timeout)91+ defer cancel()92+ run := s.run93+ if run == nil {94+ run = execCommand95+ }96+ out, err := run(ctx, argv)97+ switch {98+ case ctx.Err() == context.DeadlineExceeded:99+ return "", fmt.Errorf("jev-api-key-command: %s didn't finish in %s", argv[0], timeout)100+ case err != nil:101+ return "", fmt.Errorf("jev-api-key-command: %s: %w", argv[0], err)102+ }103+ line, _, _ := strings.Cut(string(out), "\n")104+ if line = strings.TrimSpace(line); line == "" {105+ return "", fmt.Errorf("jev-api-key-command: %s printed nothing", argv[0])106+ }107+ return line, nil108+}109+110+// execCommand runs argv with no stdin, returning stdout. What the command111+// writes to stderr is reported by its first line only.112+func execCommand(ctx context.Context, argv []string) ([]byte, error) {113+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)114+ var out, errOut bytes.Buffer115+ cmd.Stdout, cmd.Stderr = &out, &errOut116+ cmd.WaitDelay = time.Second // don't wait on children holding stdout open117+ if err := cmd.Run(); err != nil {118+ if msg, _, _ := strings.Cut(strings.TrimSpace(errOut.String()), "\n"); msg != "" {119+ return nil, fmt.Errorf("%w: %s", err, msg)120+ }121+ return nil, err122+ }123+ return out.Bytes(), nil124+}125+126+// DotEnvHasKey reports whether dir/.env sets TYPESAFE_API_KEY. It reads127+// only the variable names: the key itself is never read into memory128+// past the scanner's line, kept or shown. 012 no longer reads .env files129+// (a .env beside a downloaded sheet could send the key elsewhere); this130+// is only to suggest `012 config set-key` to people who used one.131+func DotEnvHasKey(dir string) bool {132+ f, err := os.Open(filepath.Join(dir, ".env"))133+ if err != nil {134+ return false135+ }136+ defer f.Close()137+ sc := bufio.NewScanner(f)138+ for sc.Scan() {139+ line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))140+ if name, _, ok := strings.Cut(line, "="); ok && strings.TrimSpace(name) == KeyEnv {141+ return true142+ }143+ }144+ return false145+}146+147+// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and148+// TYPESAFE_DEFAULT_MODEL from the environment only, reporting false when149+// there's no key or the base URL isn't https (or localhost). The dirs150+// are ignored: .env files are no longer read. New code resolves the key151+// with ResolveKey and takes the rest from internal/config.152+func LoadConfig(dirs ...string) (Config, bool) {153+ c := Config{APIKey: strings.TrimSpace(os.Getenv(KeyEnv)), BaseURL: strings.TrimSpace(os.Getenv("TYPESAFE_BASE_URL")),154+ Model: strings.TrimSpace(os.Getenv("TYPESAFE_DEFAULT_MODEL"))}155+ if c.BaseURL != "" && config.CheckBaseURL(c.BaseURL) != nil {156+ return c, false157+ }158+ return c, c.APIKey != ""159+}160+161+// Lazy is a Client that connects the first time a question is asked,162+// with connect, and remembers the result, error included. 012 uses it for163+// jev-api-key-command, so a password manager is only asked for the key164+// when a sheet actually uses JEV.165+func Lazy(connect func() (Client, error)) Client {166+ return &lazyClient{connect: connect}167+}168+169+type lazyClient struct {170+ once sync.Once171+ connect func() (Client, error)172+ client Client173+ err error174+}175+176+func (l *lazyClient) SystemOne(ctx context.Context, r typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {177+ l.once.Do(func() { l.client, l.err = l.connect() })178+ if l.err != nil {179+ return nil, l.err180+ }181+ return l.client.SystemOne(ctx, r)182+}@@ -0,0 +1,169 @@
1+package jev2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "runtime"9+ "slices"10+ "strings"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/012/internal/keyring"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+func env(vals map[string]string) func(string) string {19+ return func(k string) string { return vals[k] }20+}21+22+func storeWith(key string) *keyring.Memory {23+ m := &keyring.Memory{}24+ if key != "" {25+ m.Set(context.Background(), key)26+ }27+ return m28+}29+30+// fakeCommand records the argv it's asked to run and prints out.31+type fakeCommand struct {32+ argv [][]string33+ out string34+ err error35+}36+37+func (f *fakeCommand) run(_ context.Context, argv []string) ([]byte, error) {38+ f.argv = append(f.argv, argv)39+ return []byte(f.out), f.err40+}41+42+func TestResolveKeyOrder(t *testing.T) {43+ ctx := context.Background()44+ cmd := &fakeCommand{out: "from-command\nsecond line\n"}45+ s := KeySources{46+ Getenv: env(map[string]string{KeyEnv: " from-env "}),47+ Store: storeWith("from-store"),48+ Command: "pass show typesafe",49+ run: cmd.run,50+ }51+ if k, from, err := ResolveKey(ctx, s); k != "from-env" || from != KeyEnv || err != nil {52+ t.Errorf("the environment comes first: %q %q %v", k, from, err)53+ }54+ s.Getenv = env(nil)55+ if k, from, _ := ResolveKey(ctx, s); k != "from-store" || from != "test store" {56+ t.Errorf("then the credential store: %q %q", k, from)57+ }58+ if len(cmd.argv) != 0 {59+ t.Error("the command ran although the store had a key")60+ }61+ s.Store = storeWith("")62+ if k, from, _ := ResolveKey(ctx, s); k != "from-command" || from != "jev-api-key-command" {63+ t.Errorf("then the command's first line: %q %q", k, from)64+ }65+ if !slices.Equal(cmd.argv[0], []string{"pass", "show", "typesafe"}) {66+ t.Errorf("argv %q", cmd.argv[0])67+ }68+ s.Command = ""69+ if k, from, err := ResolveKey(ctx, s); k != "" || from != "" || err != nil {70+ t.Errorf("no key anywhere: %q %q %v", k, from, err)71+ }72+}73+74+func TestResolveKeyFailures(t *testing.T) {75+ ctx := context.Background()76+ broken := &keyring.Memory{Err: errors.New("keychain locked")}77+ cmd := &fakeCommand{out: "k"}78+ k, _, err := ResolveKey(ctx, KeySources{Store: broken, Command: "get-key", run: cmd.run})79+ if k != "k" || err != nil {80+ t.Errorf("a failing store falls through to the command: %q %v", k, err)81+ }82+ cmd = &fakeCommand{err: errors.New("exit status 1")}83+ _, _, err = ResolveKey(ctx, KeySources{Store: broken, Command: "get-key", run: cmd.run})84+ if err == nil || !strings.Contains(err.Error(), "keychain locked") || !strings.Contains(err.Error(), "get-key: exit status 1") {85+ t.Errorf("both failures reported: %v", err)86+ }87+ cmd = &fakeCommand{out: "\n"}88+ if _, _, err := ResolveKey(ctx, KeySources{Command: "get-key", run: cmd.run}); err == nil || !strings.Contains(err.Error(), "printed nothing") {89+ t.Errorf("empty output: %v", err)90+ }91+ if _, _, err := ResolveKey(ctx, KeySources{Command: "get 'key"}); err == nil {92+ t.Error("an unclosed quote ran")93+ }94+}95+96+// The command runs without a shell: quotes group words, and nothing is97+// expanded or piped.98+func TestKeyCommandNoShell(t *testing.T) {99+ cmd := &fakeCommand{out: "k"}100+ ResolveKey(context.Background(), KeySources{Command: `op read "op://Private/Type Safe/credential" $HOME;rm`, run: cmd.run})101+ want := []string{"op", "read", "op://Private/Type Safe/credential", "$HOME;rm"}102+ if !slices.Equal(cmd.argv[0], want) {103+ t.Errorf("argv %q, want %q", cmd.argv[0], want)104+ }105+}106+107+func TestKeyCommandRuns(t *testing.T) {108+ if runtime.GOOS == "windows" {109+ t.Skip("uses sh")110+ }111+ k, _, err := ResolveKey(context.Background(), KeySources{Command: `sh -c 'printf "real-key\n"'`})112+ if k != "real-key" || err != nil {113+ t.Errorf("%q %v", k, err)114+ }115+}116+117+func TestKeyCommandTimeout(t *testing.T) {118+ if runtime.GOOS == "windows" {119+ t.Skip("uses sleep")120+ }121+ start := time.Now()122+ _, _, err := ResolveKey(context.Background(), KeySources{Command: "sleep 5", Timeout: 100 * time.Millisecond})123+ if err == nil || !strings.Contains(err.Error(), "didn't finish in 100ms") {124+ t.Errorf("timeout: %v", err)125+ }126+ if d := time.Since(start); d > 3*time.Second {127+ t.Errorf("took %s", d)128+ }129+}130+131+func TestDotEnvHasKey(t *testing.T) {132+ dir := t.TempDir()133+ if DotEnvHasKey(dir) {134+ t.Error("no .env")135+ }136+ os.WriteFile(filepath.Join(dir, ".env"), []byte("# x\nTYPESAFE_BASE_URL=https://a\n"), 0o600)137+ if DotEnvHasKey(dir) {138+ t.Error("no key in .env")139+ }140+ os.WriteFile(filepath.Join(dir, ".env"), []byte("export TYPESAFE_API_KEY=\"x\"\n"), 0o600)141+ if !DotEnvHasKey(dir) {142+ t.Error("key in .env not noticed")143+ }144+}145+146+func TestNewClientChecksBaseURL(t *testing.T) {147+ if _, err := NewClient(Config{APIKey: "k", BaseURL: "http://evil.example"}); err == nil {148+ t.Error("http base URL accepted")149+ }150+ if _, err := NewClient(Config{APIKey: "k", BaseURL: "http://127.0.0.1:8799"}); err != nil {151+ t.Errorf("localhost: %v", err)152+ }153+}154+155+func TestLazyConnectsOnce(t *testing.T) {156+ calls := 0157+ c := Lazy(func() (Client, error) { calls++; return nil, errors.New("jev-api-key-command: op: exit status 1") })158+ if calls != 0 {159+ t.Fatal("connected before a question")160+ }161+ for range 2 {162+ if a := Ask(context.Background(), c, sheet.RemoteCall{Kind: "noul", Instructions: "Q", Criteria: [2]string{}}); !strings.Contains(a.Failed, "exit status 1") {163+ t.Errorf("answer %+v", a)164+ }165+ }166+ if calls != 1 {167+ t.Errorf("connected %d times", calls)168+ }169+}@@ -5,79 +5,37 @@
55 package jev66 77 import (8- "bufio"98 "context"109 "fmt"11- "os"12- "path/filepath"13- "strings"1410 "sync"1511 1612 typesafe "github.com/FelineStateMachine/typesafe-go"1713 14+ "github.com/FelineStateMachine/012/internal/config"1815 "github.com/FelineStateMachine/012/internal/sheet"1916 )2017 21-// Config is how to reach the service.18+// Config is how to reach the service. The key comes from ResolveKey;19+// the base URL and model from the config file or environment20+// (internal/config), never from files next to a sheet.2221 type Config struct {2322 APIKey, BaseURL, Model string2423 }2524 26-// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and27-// TYPESAFE_DEFAULT_MODEL from the environment, falling back to .env files28-// in dirs, first found wins. It reports false when there is no key, which29-// turns JEV functions off.30-func LoadConfig(dirs ...string) (Config, bool) {31- vals := map[string]string{}32- for _, dir := range dirs {33- for k, v := range readDotEnv(filepath.Join(dir, ".env")) {34- if _, seen := vals[k]; !seen {35- vals[k] = v36- }37- }38- }39- get := func(k string) string {40- if v := strings.TrimSpace(os.Getenv(k)); v != "" {41- return v42- }43- return vals[k]44- }45- c := Config{APIKey: get("TYPESAFE_API_KEY"), BaseURL: get("TYPESAFE_BASE_URL"), Model: get("TYPESAFE_DEFAULT_MODEL")}46- return c, c.APIKey != ""47-}48-49-// readDotEnv reads the TYPESAFE_ settings from a .env file: KEY=value50-// lines, optionally quoted, ignoring comments and anything else.51-func readDotEnv(path string) map[string]string {52- f, err := os.Open(path)53- if err != nil {54- return nil55- }56- defer f.Close()57- out := map[string]string{}58- sc := bufio.NewScanner(f)59- for sc.Scan() {60- line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))61- k, v, ok := strings.Cut(line, "=")62- if !ok || !strings.HasPrefix(k, "TYPESAFE_") {63- continue64- }65- v = strings.TrimSpace(v)66- if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {67- v = v[1 : len(v)-1]68- }69- out[strings.TrimSpace(k)] = v70- }71- return out72-}73-7425 // Client is the part of the TypeSafe SDK used here, so tests can fake it.7526 type Client interface {7627 SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error)7728 }7829 79-// NewClient connects to the service with c.30+// NewClient connects to the service with c. The base URL must be https31+// (or http on this machine), so the key is never sent in the clear or to32+// an address a stray file chose.8033 func NewClient(c Config) (Client, error) {34+ if c.BaseURL != "" {35+ if err := config.CheckBaseURL(c.BaseURL); err != nil {36+ return nil, err37+ }38+ }8139 opts := []typesafe.Option{typesafe.WithAPIKey(c.APIKey)}8240 if c.BaseURL != "" {8341 opts = append(opts, typesafe.WithBaseURL(c.BaseURL))@@ -4,8 +4,6 @@ import (
44 "context"55 "errors"66 "fmt"7- "os"8- "path/filepath"97 "strings"108 "testing"119 @@ -14,27 +12,6 @@ import (
1412 "github.com/FelineStateMachine/012/internal/sheet"1513 )1614 17-func TestLoadConfig(t *testing.T) {18- dir := t.TempDir()19- os.WriteFile(filepath.Join(dir, ".env"), []byte("# settings\nOTHER=x\nexport TYPESAFE_API_KEY=\"from-file\"\nTYPESAFE_DEFAULT_MODEL=jev-small\n"), 0o600)20- t.Setenv("TYPESAFE_API_KEY", "")21- t.Setenv("TYPESAFE_BASE_URL", "")22- t.Setenv("TYPESAFE_DEFAULT_MODEL", "")23-24- c, ok := LoadConfig(dir)25- if !ok || c.APIKey != "from-file" || c.Model != "jev-small" {26- t.Errorf("from .env: %+v %v", c, ok)27- }28- t.Setenv("TYPESAFE_API_KEY", "from-env")29- if c, _ := LoadConfig(dir); c.APIKey != "from-env" {30- t.Errorf("environment should win: %q", c.APIKey)31- }32- t.Setenv("TYPESAFE_API_KEY", "")33- if _, ok := LoadConfig(t.TempDir()); ok {34- t.Error("no key anywhere, but JEV is on")35- }36-}37-3815 func TestCacheQueuesOnce(t *testing.T) {3916 m := NewCache()4017 q := sheet.RemoteCall{Kind: "noul", State: "x", Instructions: "Q", Criteria: [2]string{}}@@ -10,13 +10,13 @@ import (
1010 )1111 1212 // TestLive asks the real service one question of each kind. It only runs13-// with JEV_LIVE_TEST=1 and a key in the environment or the repo's .env.13+// with JEV_LIVE_TEST=1 and TYPESAFE_API_KEY in the environment.1414 func TestLive(t *testing.T) {1515 if os.Getenv("JEV_LIVE_TEST") != "1" {1616 t.Skip("set JEV_LIVE_TEST=1 to call the live JEV service")1717 }18- cfg, ok := LoadConfig("../..")19- if !ok {18+ cfg := Config{APIKey: os.Getenv(KeyEnv), BaseURL: os.Getenv("TYPESAFE_BASE_URL"), Model: os.Getenv("TYPESAFE_DEFAULT_MODEL")}19+ if cfg.APIKey == "" {2020 t.Skip("no TYPESAFE_API_KEY")2121 }2222 client, err := NewClient(cfg)@@ -0,0 +1,78 @@
1+// Package keyring keeps the JEV API key in the operating system's2+// credential store: the macOS Keychain, the Windows Credential Manager,3+// or the freedesktop Secret Service on Linux and the BSDs (GNOME4+// Keyring, KWallet, KeePassXC). It needs nothing beyond the standard5+// library and golang.org/x/sys: on macOS and Linux it runs the system's6+// own command line tools (security, secret-tool), handing them the7+// secret on stdin so it never appears in a process list; on Windows it8+// calls the Credential Manager API directly. See docs/architecture.md.9+package keyring10+11+import (12+ "context"13+ "errors"14+ "sync"15+)16+17+// Service and Account name the stored item.18+const (19+ Service = "012"20+ Account = "jev-api-key"21+ Label = "012 JEV API key"22+)23+24+// ErrNotFound means no key is stored.25+var ErrNotFound = errors.New("no key in the credential store")26+27+// Store keeps one secret.28+type Store interface {29+ Get(ctx context.Context) (string, error)30+ Set(ctx context.Context, secret string) error31+ Delete(ctx context.Context) error32+ // Name is what the store is called, e.g. "macOS Keychain".33+ Name() string34+}35+36+// Memory is a Store in memory, for tests.37+type Memory struct {38+ mu sync.Mutex39+ secret string40+ Err error // returned by every call when set41+}42+43+func (m *Memory) Get(context.Context) (string, error) {44+ m.mu.Lock()45+ defer m.mu.Unlock()46+ if m.Err != nil {47+ return "", m.Err48+ }49+ if m.secret == "" {50+ return "", ErrNotFound51+ }52+ return m.secret, nil53+}54+55+func (m *Memory) Set(_ context.Context, s string) error {56+ m.mu.Lock()57+ defer m.mu.Unlock()58+ if m.Err != nil {59+ return m.Err60+ }61+ m.secret = s62+ return nil63+}64+65+func (m *Memory) Delete(context.Context) error {66+ m.mu.Lock()67+ defer m.mu.Unlock()68+ if m.Err != nil {69+ return m.Err70+ }71+ if m.secret == "" {72+ return ErrNotFound73+ }74+ m.secret = ""75+ return nil76+}77+78+func (m *Memory) Name() string { return "test store" }@@ -0,0 +1,122 @@
1+package keyring2+3+import (4+ "context"5+ "encoding/hex"6+ "errors"7+ "strings"8+ "testing"9+)10+11+// These tests never run security or secret-tool: a fake runner records12+// what would run, so the real keychain is never touched.13+14+const secret = "ts-secret-key-123"15+16+type call struct {17+ name string18+ args []string19+ stdin string20+}21+22+type fakeRunner struct {23+ calls []call24+ out string25+ err error26+}27+28+func (f *fakeRunner) run(_ context.Context, name string, args []string, stdin string) (string, error) {29+ f.calls = append(f.calls, call{name, args, stdin})30+ return f.out, f.err31+}32+33+// noSecretInArgs fails if the secret is in any argument, where other34+// users could see it in the process list.35+func noSecretInArgs(t *testing.T, calls []call) {36+ t.Helper()37+ for _, c := range calls {38+ for _, a := range c.args {39+ if strings.Contains(a, secret) || strings.Contains(a, hex.EncodeToString([]byte(secret))) {40+ t.Errorf("secret in argv: %s %q", c.name, c.args)41+ }42+ }43+ }44+}45+46+func TestKeychain(t *testing.T) {47+ ctx := context.Background()48+ f := &fakeRunner{}49+ k := keychain{run: f.run}50+ if err := k.Set(ctx, secret); err != nil {51+ t.Fatal(err)52+ }53+ noSecretInArgs(t, f.calls)54+ c := f.calls[0]55+ if c.name != "/usr/bin/security" || len(c.args) != 1 || c.args[0] != "-i" {56+ t.Errorf("set ran %s %q", c.name, c.args)57+ }58+ if !strings.Contains(c.stdin, "add-generic-password -U -s 012 -a jev-api-key") || !strings.Contains(c.stdin, "-X "+hex.EncodeToString([]byte(secret))) {59+ t.Errorf("set wrote %q", c.stdin)60+ }61+62+ f.out = secret + "\n"63+ if got, err := k.Get(ctx); err != nil || got != secret {64+ t.Errorf("get: %q %v", got, err)65+ }66+ f.out, f.err = "", &exitError{code: 44, stderr: "The specified item could not be found in the keychain."}67+ if _, err := k.Get(ctx); !errors.Is(err, ErrNotFound) {68+ t.Errorf("missing item: %v", err)69+ }70+ if err := k.Delete(ctx); !errors.Is(err, ErrNotFound) {71+ t.Errorf("delete missing: %v", err)72+ }73+ f.err = &exitError{code: 51, stderr: "user interaction is not allowed"}74+ if _, err := k.Get(ctx); err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "interaction") {75+ t.Errorf("other failure: %v", err)76+ }77+}78+79+func TestSecretService(t *testing.T) {80+ ctx := context.Background()81+ f := &fakeRunner{}82+ s := secretService{run: f.run}83+ if err := s.Set(ctx, secret); err != nil {84+ t.Fatal(err)85+ }86+ noSecretInArgs(t, f.calls)87+ c := f.calls[0]88+ if c.name != "secret-tool" || c.args[0] != "store" || c.stdin != secret {89+ t.Errorf("set ran %s %q with stdin %q", c.name, c.args, c.stdin)90+ }91+ if strings.Join(c.args[2:], " ") != "service 012 account jev-api-key" {92+ t.Errorf("attributes %q", c.args)93+ }94+95+ f.out = secret96+ if got, err := s.Get(ctx); err != nil || got != secret {97+ t.Errorf("get: %q %v", got, err)98+ }99+ f.out, f.err = "", &exitError{code: 1}100+ if _, err := s.Get(ctx); !errors.Is(err, ErrNotFound) {101+ t.Errorf("missing: %v", err)102+ }103+ f.err = &exitError{code: 1, stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY"}104+ if _, err := s.Get(ctx); err == nil || errors.Is(err, ErrNotFound) {105+ t.Errorf("no D-Bus should be an error, not a missing key: %v", err)106+ }107+}108+109+func TestMemory(t *testing.T) {110+ ctx := context.Background()111+ m := &Memory{}112+ if _, err := m.Get(ctx); !errors.Is(err, ErrNotFound) {113+ t.Error(err)114+ }115+ m.Set(ctx, "k")116+ if got, _ := m.Get(ctx); got != "k" {117+ t.Error(got)118+ }119+ if err := m.Delete(ctx); err != nil {120+ t.Error(err)121+ }122+}@@ -0,0 +1,64 @@
1+//go:build fakekeyring2+3+package keyring4+5+import (6+ "context"7+ "errors"8+ "os"9+ "path/filepath"10+ "strings"11+)12+13+// System, in binaries built with -tags fakekeyring (the e2e tests), is a14+// file in $XDG_CONFIG_HOME/012, which the tests point at a temporary15+// directory, so they never reach a real credential store.16+func System() Store { return fileStore{} }17+18+type fileStore struct{}19+20+func (fileStore) path() (string, error) {21+ x := os.Getenv("XDG_CONFIG_HOME")22+ if x == "" {23+ return "", errors.New("the test credential store needs XDG_CONFIG_HOME")24+ }25+ return filepath.Join(x, "012", "test-credential-store"), nil26+}27+28+func (fileStore) Name() string { return "test credential store" }29+30+func (f fileStore) Get(context.Context) (string, error) {31+ p, err := f.path()32+ if err != nil {33+ return "", err34+ }35+ b, err := os.ReadFile(p)36+ if errors.Is(err, os.ErrNotExist) {37+ return "", ErrNotFound38+ }39+ return strings.TrimSpace(string(b)), err40+}41+42+func (f fileStore) Set(_ context.Context, secret string) error {43+ p, err := f.path()44+ if err != nil {45+ return err46+ }47+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {48+ return err49+ }50+ return os.WriteFile(p, []byte(secret), 0o600)51+}52+53+func (f fileStore) Delete(context.Context) error {54+ p, err := f.path()55+ if err != nil {56+ return err57+ }58+ if err := os.Remove(p); errors.Is(err, os.ErrNotExist) {59+ return ErrNotFound60+ } else if err != nil {61+ return err62+ }63+ return nil64+}@@ -0,0 +1,104 @@
1+//go:build !fakekeyring2+3+package keyring4+5+import (6+ "context"7+ "errors"8+ "fmt"9+ "unsafe"10+11+ "golang.org/x/sys/windows"12+)13+14+// System is the Windows Credential Manager, as a generic credential15+// named "012:jev-api-key", through advapi32's CredReadW, CredWriteW and16+// CredDeleteW.17+func System() Store { return credManager{} }18+19+var (20+ advapi32 = windows.NewLazySystemDLL("advapi32.dll")21+ credRead = advapi32.NewProc("CredReadW")22+ credWrite = advapi32.NewProc("CredWriteW")23+ credDelete = advapi32.NewProc("CredDeleteW")24+ credFree = advapi32.NewProc("CredFree")25+)26+27+const (28+ credTypeGeneric = 129+ credPersistLocalMachine = 230+ target = Service + ":" + Account31+)32+33+// credential is CREDENTIALW.34+type credential struct {35+ Flags uint3236+ Type uint3237+ TargetName *uint1638+ Comment *uint1639+ LastWritten windows.Filetime40+ CredentialBlobSize uint3241+ CredentialBlob *byte42+ Persist uint3243+ AttributeCount uint3244+ Attributes uintptr45+ TargetAlias *uint1646+ UserName *uint1647+}48+49+type credManager struct{}50+51+func (credManager) Name() string { return "Windows Credential Manager" }52+53+func (credManager) Get(context.Context) (string, error) {54+ name, err := windows.UTF16PtrFromString(target)55+ if err != nil {56+ return "", err57+ }58+ var c *credential59+ r, _, callErr := credRead.Call(uintptr(unsafe.Pointer(name)), credTypeGeneric, 0, uintptr(unsafe.Pointer(&c)))60+ if r == 0 {61+ return "", wrap(callErr)62+ }63+ defer credFree.Call(uintptr(unsafe.Pointer(c)))64+ return string(unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)), nil65+}66+67+func (credManager) Set(_ context.Context, secret string) error {68+ if len(secret) == 0 {69+ return errors.New("empty key")70+ }71+ name, err := windows.UTF16PtrFromString(target)72+ if err != nil {73+ return err74+ }75+ user, _ := windows.UTF16PtrFromString(Account)76+ comment, _ := windows.UTF16PtrFromString(Label)77+ blob := []byte(secret)78+ c := credential{79+ Type: credTypeGeneric, TargetName: name, Comment: comment, UserName: user,80+ CredentialBlobSize: uint32(len(blob)), CredentialBlob: &blob[0], Persist: credPersistLocalMachine,81+ }82+ if r, _, callErr := credWrite.Call(uintptr(unsafe.Pointer(&c)), 0); r == 0 {83+ return wrap(callErr)84+ }85+ return nil86+}87+88+func (credManager) Delete(context.Context) error {89+ name, err := windows.UTF16PtrFromString(target)90+ if err != nil {91+ return err92+ }93+ if r, _, callErr := credDelete.Call(uintptr(unsafe.Pointer(name)), credTypeGeneric, 0); r == 0 {94+ return wrap(callErr)95+ }96+ return nil97+}98+99+func wrap(err error) error {100+ if errors.Is(err, windows.ERROR_NOT_FOUND) {101+ return ErrNotFound102+ }103+ return fmt.Errorf("credential manager: %w", err)104+}@@ -0,0 +1,142 @@
1+package keyring2+3+import (4+ "bytes"5+ "context"6+ "encoding/hex"7+ "errors"8+ "fmt"9+ "os/exec"10+ "strings"11+)12+13+// runFunc runs a program with args, writing stdin to it, and returns its14+// stdout. Tests replace it to see exactly what would run.15+type runFunc func(ctx context.Context, name string, args []string, stdin string) (string, error)16+17+// exitError is a program that ran and failed.18+type exitError struct {19+ code int20+ stderr string21+}22+23+func (e *exitError) Error() string {24+ if e.stderr != "" {25+ return fmt.Sprintf("exit status %d: %s", e.code, e.stderr)26+ }27+ return fmt.Sprintf("exit status %d", e.code)28+}29+30+func run(ctx context.Context, name string, args []string, stdin string) (string, error) {31+ cmd := exec.CommandContext(ctx, name, args...)32+ cmd.Stdin = strings.NewReader(stdin)33+ var out, errOut bytes.Buffer34+ cmd.Stdout, cmd.Stderr = &out, &errOut35+ err := cmd.Run()36+ var ee *exec.ExitError37+ if errors.As(err, &ee) {38+ return out.String(), &exitError{code: ee.ExitCode(), stderr: firstLine(errOut.String())}39+ }40+ return out.String(), err41+}42+43+func firstLine(s string) string {44+ s, _, _ = strings.Cut(strings.TrimSpace(s), "\n")45+ return strings.TrimSpace(s)46+}47+48+func exitCode(err error) int {49+ var ee *exitError50+ if errors.As(err, &ee) {51+ return ee.code52+ }53+ return -154+}55+56+// keychain is the macOS Keychain, through /usr/bin/security. The secret57+// is written with "security -i", which reads its command from stdin, as58+// hex (-X), so it's never an argument of any process.59+type keychain struct{ run runFunc }60+61+// errItemNotFound is security's exit status for a missing item.62+const errItemNotFound = 4463+64+const securityPath = "/usr/bin/security"65+66+func (k keychain) Name() string { return "macOS Keychain" }67+68+func (k keychain) Get(ctx context.Context) (string, error) {69+ out, err := k.run(ctx, securityPath, []string{"find-generic-password", "-s", Service, "-a", Account, "-w"}, "")70+ if exitCode(err) == errItemNotFound {71+ return "", ErrNotFound72+ }73+ if err != nil {74+ return "", fmt.Errorf("keychain: %w", err)75+ }76+ return strings.TrimRight(out, "\r\n"), nil77+}78+79+func (k keychain) Set(ctx context.Context, secret string) error {80+ cmd := fmt.Sprintf("add-generic-password -U -s %s -a %s -l %q -X %s\n", Service, Account, Label, hex.EncodeToString([]byte(secret)))81+ if _, err := k.run(ctx, securityPath, []string{"-i"}, cmd); err != nil {82+ return fmt.Errorf("keychain: %w", err)83+ }84+ return nil85+}86+87+func (k keychain) Delete(ctx context.Context) error {88+ _, err := k.run(ctx, securityPath, []string{"delete-generic-password", "-s", Service, "-a", Account}, "")89+ if exitCode(err) == errItemNotFound {90+ return ErrNotFound91+ }92+ if err != nil {93+ return fmt.Errorf("keychain: %w", err)94+ }95+ return nil96+}97+98+// secretService is the freedesktop Secret Service (GNOME Keyring,99+// KWallet, KeePassXC), through libsecret's secret-tool, which reads the100+// secret to store from stdin.101+type secretService struct{ run runFunc }102+103+func (s secretService) Name() string { return "Secret Service" }104+105+func attrs(verb string, extra ...string) []string {106+ return append(append([]string{verb}, extra...), "service", Service, "account", Account)107+}108+109+func (s secretService) Get(ctx context.Context) (string, error) {110+ out, err := s.run(ctx, "secret-tool", attrs("lookup"), "")111+ var ee *exitError112+ switch {113+ case err == nil && out != "":114+ return strings.TrimRight(out, "\r\n"), nil115+ case err == nil, errors.As(err, &ee) && ee.code == 1 && ee.stderr == "":116+ return "", ErrNotFound // secret-tool exits 1, silently, when nothing matches117+ }118+ return "", s.wrap(err)119+}120+121+func (s secretService) Set(ctx context.Context, secret string) error {122+ _, err := s.run(ctx, "secret-tool", attrs("store", "--label="+Label), secret)123+ return s.wrap(err)124+}125+126+func (s secretService) Delete(ctx context.Context) error {127+ if _, err := s.Get(ctx); err != nil {128+ return err129+ }130+ _, err := s.run(ctx, "secret-tool", attrs("clear"), "")131+ return s.wrap(err)132+}133+134+func (s secretService) wrap(err error) error {135+ if errors.Is(err, exec.ErrNotFound) {136+ return errors.New("secret-tool isn't installed (libsecret-tools on Debian and Ubuntu, libsecret on Fedora and Arch)")137+ }138+ if err != nil {139+ return fmt.Errorf("secret service: %w", err)140+ }141+ return nil142+}@@ -8,6 +8,8 @@
88 package serve99 1010 import (11+ "github.com/FelineStateMachine/012/internal/config"12+1113 "errors"1214 "fmt"1315 "os"@@ -52,8 +54,8 @@ func Defaults() Options {
5254 if home, err := os.UserHomeDir(); err == nil {5355 o.AuthorizedKeys = filepath.Join(home, ".ssh", "authorized_keys")5456 }55- if cfg, err := os.UserConfigDir(); err == nil {56- o.HostKey = filepath.Join(cfg, "012", "ssh_host_ed25519_key")57+ if dir, err := config.Dir(); err == nil {58+ o.HostKey = filepath.Join(dir, "ssh_host_ed25519_key")5759 }5860 return o5961 }@@ -25,6 +25,8 @@ import (
2525 // Images use the terminal's own palette, asked for with OSC 4 once2626 // graphics are known to work, so bars match the text legend.2727 type terminal struct {28+ noImages bool // chart-images = false in the config29+ noNotify bool // notifications = false in the config2830 kitty bool // answers kitty graphics queries2931 tmux bool // inside tmux: sequences for the outer terminal need passthrough3032 cellW, cellH int // cell size in pixels, 0 until reported@@ -129,20 +131,31 @@ func (t *terminal) color(i int) color.RGBA {
129131 return xtermColors[i%16]130132 }131133 132-// chartPalette is the theme's series colors as the terminal draws them.134+// chartPalette is the theme's series colors as the terminal draws them,135+// or as the theme's color scheme has them.133136 func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {137+ col := t.color138+ if th.Palette != nil {139+ col = func(i int) color.RGBA {140+ r, g, b, _ := th.Palette.ANSI[i%16].RGBA()141+ return color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}142+ }143+ }134144 var p chart.Palette135145 for i, idx := range th.SeriesANSI {136- p.Series[i] = t.color(idx)146+ p.Series[i] = col(idx)137147 }138- p.Grid = t.color(8) // bright black, like the text axes148+ p.Grid = col(8) // bright black, like the text axes139149 p.Grid.A = 90140150 return p141151 }142152 153+// images reports whether charts are drawn as images.154+func (t *terminal) images() bool { return t.kitty && !t.noImages }155+143156 // chartOptions are the drawing options for charts on this terminal.144157 func (t *terminal) chartOptions() chart.Options {145- return chart.Options{Image: t.kitty, CellW: t.cellW, CellH: t.cellH}158+ return chart.Options{Image: t.images(), CellW: t.cellW, CellH: t.cellH}146159 }147160 148161 // syncImages sends the images of charts that changed since they were last@@ -151,8 +164,8 @@ func (t *terminal) chartOptions() chart.Options {
151164 // after every message, so images follow edits, recalculation, resizing152165 // and the theme.153166 func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *theme.Theme) tea.Cmd {154- if !t.kitty {155- return nil167+ if !t.images() {168+ return t.freeImages()156169 }157170 var out strings.Builder158171 o := t.chartOptions()@@ -202,6 +215,19 @@ func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *th
202215 return tea.Raw(out.String())203216 }204217 218+// freeImages frees every image sent, when images are turned off.219+func (t *terminal) freeImages() tea.Cmd {220+ if len(t.sent) == 0 {221+ return nil222+ }223+ var b strings.Builder224+ for id := range t.sent {225+ b.WriteString(chart.Delete(id, t.wrap))226+ delete(t.sent, id)227+ }228+ return tea.Raw(b.String())229+}230+205231 // release undoes the startup modes and frees the chart images.206232 func (t *terminal) release() string {207233 var b strings.Builder@@ -216,7 +242,7 @@ func (t *terminal) release() string {
216242 // notification (OSC 9) when the terminal window isn't focused. When it is,217243 // the screen already shows the result.218244 func (t *terminal) notify(msg string) tea.Cmd {219- if !t.blurred {245+ if !t.blurred || t.noNotify {220246 return nil221247 }222248 msg = strings.Map(func(r rune) rune {@@ -136,7 +136,7 @@ func (j *jevRunner) line(th *theme.Theme, calls []sheet.RemoteCall) string {
136136 return ""137137 }138138 if j == nil {139- return th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")139+ return th.Warning.Render("JEV functions need an API key: File > Settings > JEV API key")140140 }141141 var parts []string142142 for _, c := range calls {@@ -98,7 +98,7 @@ func TestJEVPendingAndBusy(t *testing.T) {
9898 func TestJEVWithoutKeyExplains(t *testing.T) {9999 m := tallModel()100100 press(t, m, `=JEV.TEST("x", "Q")`, "<enter>", "<up>")101- if !strings.Contains(line(m, contextLine), "TYPESAFE_API_KEY") {101+ if !strings.Contains(line(m, contextLine), "Settings > JEV API key") {102102 t.Errorf("context line %q", line(m, contextLine))103103 }104104 if commands["jev.refresh"].enabled(m) {@@ -34,7 +34,11 @@ var menuBar = []menuDef{
3434 {title: "File", accel: 'f', items: []menuItem{3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,37- {title: "Settings", items: []menuItem{{cmd: "settings.decimal"}, {cmd: "settings.vim"}}}, sep,37+ {title: "Settings", items: []menuItem{38+ {cmd: "settings.decimal"}, {cmd: "settings.vim"}, sep,39+ {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,40+ {cmd: "settings.config_edit"}, {cmd: "settings.config_reload"},41+ }}, sep,3842 {cmd: "quit"},3943 }},4044 {title: "Edit", accel: 'e', items: []menuItem{@@ -86,9 +86,9 @@ type Model struct {
8686 xfer transfer // imports and downloads: transfer.go8787 jev *jevRunner // answers JEV functions; nil without an API key: jev.go8888 term terminal // what the terminal supports: graphics.go89+ prefs prefs // the settings in effect and the theme chosen: prefs.go8990 90- prefs prefs // the user's preferences, kept across files: prefs.go91- vim vimState // a vim key sequence in progress: vim.go91+ vim vimState // a vim key sequence in progress: vim.go9292 9393 keyAt time.Time // when the key the next frame answers was pressed, for telemetry9494 @@ -97,7 +97,7 @@ type Model struct {
9797 9898 // New returns a model editing s. filename may be empty.9999 func New(s *sheet.Sheet, filename string) *Model {100- m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}, prefs: initialPrefs()}100+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}}101101 if filename != "" {102102 m.disk = diskStamp(filename)103103 }@@ -132,8 +132,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132132 case tea.WindowSizeMsg:133133 m.width, m.height = msg.Width, msg.Height134134 beforeMode = -1 // keep the focus visible after a resize135- case tea.BackgroundColorMsg:136- m.th = theme.New(msg.IsDark())137135 case tea.KeyPressMsg:138136 cmd = m.handleKey(msg)139137 case tea.PasteMsg:@@ -164,7 +162,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164162 case importedMsg, importTickMsg, exportedMsg:165163 cmd = m.handleTransfer(msg)166164 default:167- cmd = m.term.handle(msg)165+ if !m.handlePrefs(msg) {166+ cmd = m.term.handle(msg)167+ }168168 }169169 // Scroll only when the focus moves, so the mouse wheel can look around170170 // without the view snapping back, as in Sheets.@@ -61,22 +61,26 @@ func TestPaletteSearchRunsCommand(t *testing.T) {
6161 6262 func TestPaletteMatchesMenuPath(t *testing.T) {6363 m := newModel()64- // "file" matches every File menu command through its path.64+ // "file" matches "Open config file" by its title first, then every65+ // File menu command through its path.6566 press(t, m, "<ctrl+k>", "file")6667 var got []string6768 for _, pm := range openPicker(t, m).shown {6869 got = append(got, pm.item.title)6970 }70- slices.Sort(got[:6])71- if s := strings.Join(got[:6], ","); s != "Import,New,Open,Quit,Save,Save as" {71+ if got[0] != "Open config file" {72+ t.Errorf("first match for file: %q", got[0])73+ }74+ slices.Sort(got[1:7])75+ if s := strings.Join(got[1:7], ","); s != "Import,New,Open,Quit,Save,Save as" {7276 t.Errorf("file matched %s", strings.Join(got, ","))7377 }74- press(t, m, "<down>", "<down>")78+ press(t, m, "<down>", "<down>", "<down>")7579 if selected(t, m) != "Save" || !strings.Contains(line(m, m.height-1), "Save the sheet") {7680 t.Errorf("selected %q, status %q", selected(t, m), line(m, m.height-1))7781 }7882 // Up from the top wraps to the bottom.79- press(t, m, "<up>", "<up>", "<up>")83+ press(t, m, "<up>", "<up>", "<up>", "<up>")8084 if p := openPicker(t, m); p.sel != len(p.shown)-1 {8185 t.Errorf("wrapped to %d of %d", p.sel, len(p.shown))8286 }@@ -9,6 +9,7 @@ import (
99 "time"1010 1111 tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"1213 "github.com/charmbracelet/x/ansi"1314 1415 "github.com/FelineStateMachine/012/internal/sheet"@@ -38,11 +39,13 @@ func (m *Model) View() tea.View {
3839 for i, l := range lines {3940 lines[i] = ansi.Truncate(l, m.width, "")4041 }42+ m.drawBars(lines)4143 4244 content := strings.Join(lines, "\n")4345 if boxes := m.floating(); len(boxes) > 0 || hasCharts(m) {4446 content = m.compose(content, boxes)4547 }48+ content = m.fillScreen(content)4649 v := tea.NewView(content)4750 v.AltScreen = true4851 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go@@ -65,6 +68,46 @@ func (m *Model) View() tea.View {
6568 return v6669 }6770 71+// drawBars draws the menu bar, formula bar, context line, column header72+// row and status line as full-width bands in the theme's bar roles,73+// before overlays are composited over them.74+func (m *Model) drawBars(lines []string) {75+ bands := []struct {76+ line int77+ role lipgloss.Style78+ }{79+ {menuLine, m.th.MenuBarRow}, {formulaLine, m.th.FormulaBarRow}, {contextLine, m.th.ContextRow},80+ {headerLine, m.th.ColumnHeaderRow}, {len(lines) - 1, m.th.StatusBarRow},81+ }82+ for _, b := range bands {83+ if b.line < len(lines) {84+ lines[b.line] = theme.Fill(lines[b.line], m.width, b.role)85+ }86+ }87+}88+89+// fillScreen draws everything else on the theme's background, out to the90+// edges, when the theme has one of its own.91+func (m *Model) fillScreen(content string) string {92+ if isEmpty(m.th.Screen) {93+ return content94+ }95+ lines := strings.Split(content, "\n")96+ for len(lines) < m.height {97+ lines = append(lines, "")98+ }99+ for i, l := range lines {100+ lines[i] = theme.Fill(l, m.width, m.th.Screen)101+ }102+ return strings.Join(lines, "\n")103+}104+105+func isEmpty(s lipgloss.Style) bool {106+ _, noBg := s.GetBackground().(lipgloss.NoColor)107+ _, noFg := s.GetForeground().(lipgloss.NoColor)108+ return noBg && noFg109+}110+68111 // timeFrame reports a frame begun at start to telemetry, with how long69112 // ago the key press it answers came in.70113 func (m *Model) timeFrame(start time.Time) {@@ -219,7 +262,7 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
219262 case m.mode == modeEnter, m.mode == modeEdit:220263 return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true221264 case m.mode == modePrompt && !m.pointing():222- return ansi.StringWidth(m.prompt.prefix() + m.line.head()), contextLine, true265+ return ansi.StringWidth(m.prompt.prefix() + m.prompt.head(m)), contextLine, true223266 }224267 return 0, 0, false225268 }@@ -1,48 +1,204 @@
11 package ui22 3-import tea "charm.land/bubbletea/v2"3+import (4+ "context"5+ "errors"6+ "fmt"7+ "os"8+ "strings"9+ "time"410 5-// Preferences belong to the user, not the sheet: they aren't saved in6-// .012 files or undone, and File > New and Open keep them. Each one is a7-// checked command in File > Settings.11+ tea "charm.land/bubbletea/v2"812 9-// prefs are the user's preferences.13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/jev"15+ "github.com/FelineStateMachine/012/internal/keyring"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// Settings are what the app takes from outside the workbook: the config20+// file's options, and the credential store the JEV API key lives in.21+// cmd/012 passes them to Configure; tests pass fakes, so the user's own22+// config and keychain are never touched.23+type Settings struct {24+ Config *config.Config25+ Reload func() *config.Config // reads the config file again; nil disables Reload config26+ ThemesDir string // the user's theme files27+ Keys keyring.Store // where JEV API key stores the key; nil disables it28+ // Connect makes a JEV client for the configured service with key.29+ Connect func(key string) (jev.Client, error)30+ Notes []string // said on the context line at startup, e.g. config warnings31+}32+33+// prefs is the settings in effect: the component behind File >34+// Settings, the theme picker and the API key prompt.1035 type prefs struct {11- vim bool // vim keys in READY mode: see vim.go36+ Settings37+ light bool // the terminal's background is light (dark until it says)38+ preview string // a theme being previewed by the picker39+ vim bool // vim keys in READY mode (keymap = vim): see vim.go1240 }1341 14-// initialPrefs is what a new Model starts with. It is the seam where15-// preferences are read from the user's configuration (for vim keys, the16-// option keymap = default|vim); until that's wired, the defaults.17-func initialPrefs() prefs {18- return prefs{}42+// Configure applies settings: the theme, chart images, notifications,43+// and notes for the context line.44+func (m *Model) Configure(s Settings) {45+ m.prefs.Settings = s46+ notes := s.Notes47+ if p := m.applyConfig(); p != "" {48+ notes = append(notes, p)49+ }50+ if len(notes) > 0 {51+ m.note = m.th.Warning.Render(strings.Join(notes, " "))52+ }1953 }2054 21-// prefsChanged is called after the user changes a preference, with the22-// new preferences in m.prefs. It is the seam where they are written back23-// to the user's configuration; nothing persists them yet.24-func (m *Model) prefsChanged() tea.Cmd {25- return nil55+// applyConfig applies the options that take effect live and reports56+// problems it finds, such as an unknown theme.57+func (m *Model) applyConfig() string {58+ c := m.prefs.Config59+ m.term.noImages = !c.Bool("chart-images")60+ m.term.noNotify = !c.Bool("notifications")61+ m.SetVimKeys(c.String("keymap") == "vim")62+ return m.applyTheme()2663 }2764 28-// SetVimKeys turns vim keys on or off, as the setting does, for callers29-// that know the user's choice at startup.30-func (m *Model) SetVimKeys(on bool) {31- m.prefs.vim = on32- m.vim = vimState{}65+// applyTheme draws with the theme chosen for the terminal's background,66+// or the one being previewed. It returns why a theme couldn't be used.67+func (m *Model) applyTheme() string {68+ name := m.prefs.preview69+ if name == "" {70+ name = m.prefs.Config.Theme().Pick(!m.prefs.light)71+ }72+ th, err := theme.Resolve(name, !m.prefs.light, m.prefs.ThemesDir)73+ m.th = th74+ if err != nil {75+ return err.Error()76+ }77+ return ""3378 }3479 3580 func init() {36- register(&command{id: "settings.vim", title: "Vim keys",37- desc: "Move with hjkl, counts and operators (dd, yy, p), visual selection with v and V, and : commands",38- run: func(m *Model) tea.Cmd {39- m.SetVimKeys(!m.prefs.vim)40- m.note = "Vim keys off: typing replaces the cell, as in Sheets"41- if m.prefs.vim {42- m.note = "Vim keys on: i or Enter edits, : runs a command, F1 lists the keys"81+ register(82+ &command{id: "settings.theme", title: "Theme",83+ desc: "Pick a color theme, previewing each as you move; Esc keeps the one you had",84+ run: func(m *Model) tea.Cmd { m.openOverlay(newThemePicker(m)); return nil }},85+ &command{id: "settings.config_edit", title: "Open config file",86+ desc: "Edit the settings file in $VISUAL or $EDITOR; 012 reloads it when you close the editor",87+ enabled: func(m *Model) bool { return m.prefs.Config != nil && m.prefs.Config.Path != "" },88+ run: (*Model).editConfig},89+ &command{id: "settings.config_reload", title: "Reload config",90+ desc: "Read the settings file again and apply the theme and display options",91+ enabled: func(m *Model) bool { return m.prefs.Reload != nil },92+ run: func(m *Model) tea.Cmd { m.reloadConfig(); return nil }},93+ &command{id: "settings.jev_key", title: "JEV API key",94+ desc: "Store the TypeSafe API key for JEV functions in the OS credential store",95+ enabled: func(m *Model) bool { return m.prefs.Keys != nil },96+ run: (*Model).askKey},97+ )98+}99+100+// configEditedMsg says the editor opened by Open config file exited.101+type configEditedMsg struct{ err error }102+103+func (m *Model) editConfig() tea.Cmd {104+ path := m.prefs.Config.Path105+ if err := config.EnsureFile(path); err != nil {106+ m.fail("Can't create " + path + ": " + err.Error())107+ return nil108+ }109+ cmd, err := config.Editor(path, os.Getenv)110+ if err != nil {111+ m.fail("Can't start the editor: " + err.Error())112+ return nil113+ }114+ return tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })115+}116+117+// reloadConfig reads the config file again, applies what can change118+// live, and says what happened.119+func (m *Model) reloadConfig() {120+ c := m.prefs.Reload()121+ m.prefs.Config = c122+ var problems []string123+ for _, w := range c.Warnings {124+ problems = append(problems, w.Short())125+ }126+ if p := m.applyConfig(); p != "" {127+ problems = append(problems, p)128+ }129+ if len(problems) > 0 {130+ m.note = m.th.Warning.Render("Config reloaded with problems: " + strings.Join(problems, "; "))131+ return132+ }133+ m.note = "Config reloaded from " + config.Tilde(c.Path)134+}135+136+// keySavedMsg reports storing the API key, and the client made with it.137+type keySavedMsg struct {138+ client jev.Client139+ err error140+}141+142+// askKey asks for the API key on the context line, masked, and stores it.143+func (m *Model) askKey() tea.Cmd {144+ m.openPrompt(&prompt{kind: promptText, label: "TypeSafe API key:", indicator: "KEY", secret: true,145+ onText: func(m *Model, key string) tea.Cmd {146+ if key == "" {147+ m.note = "No key entered; nothing changed"148+ return nil43149 }44- return m.prefsChanged()45- },46- checked: func(m *Model) bool { return m.prefs.vim },47- })150+ m.note = "Saving the key in the " + m.prefs.Keys.Name() + "…"151+ store, connect := m.prefs.Keys, m.prefs.Connect152+ return func() tea.Msg {153+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)154+ defer cancel()155+ if err := store.Set(ctx, key); err != nil {156+ return keySavedMsg{err: err}157+ }158+ if connect == nil {159+ return keySavedMsg{err: errors.New("JEV isn't available in this session")}160+ }161+ client, err := connect(key)162+ return keySavedMsg{client: client, err: err}163+ }164+ }}, "")165+ return nil166+}167+168+// keySaved turns JEV on with the new key.169+func (m *Model) keySaved(msg keySavedMsg) {170+ if msg.err != nil {171+ m.fail("Couldn't save the API key: " + msg.err.Error())172+ return173+ }174+ if m.jev == nil {175+ m.EnableJEV(msg.client, jev.NewCache())176+ } else {177+ m.jev.client = msg.client178+ }179+ m.sheet.RecalcVolatile()180+ m.note = fmt.Sprintf("API key saved in the %s; JEV functions are on", m.prefs.Keys.Name())181+}182+183+// handlePrefs handles the messages settings get: the terminal's184+// background (which picks the theme), the editor closing, the key saved.185+func (m *Model) handlePrefs(msg tea.Msg) bool {186+ switch msg := msg.(type) {187+ case tea.BackgroundColorMsg:188+ m.prefs.light = !msg.IsDark()189+ if p := m.applyTheme(); p != "" && m.note == "" {190+ m.note = m.th.Warning.Render(p)191+ }192+ case configEditedMsg:193+ if msg.err != nil {194+ m.fail("The editor failed: " + msg.err.Error())195+ } else if m.prefs.Reload != nil {196+ m.reloadConfig()197+ }198+ case keySavedMsg:199+ m.keySaved(msg)200+ default:201+ return false202+ }203+ return true48204 }@@ -0,0 +1,221 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+ uv "github.com/charmbracelet/ultraviolet"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/config"16+ "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"18+)19+20+// configured returns a model with settings from a config file in a21+// temporary directory and a credential store in memory.22+func configured(t *testing.T, file string) (*Model, string, *keyring.Memory) {23+ t.Helper()24+ path := filepath.Join(t.TempDir(), "config")25+ if file != "" {26+ os.WriteFile(path, []byte(file), 0o600)27+ }28+ load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }29+ store := &keyring.Memory{}30+ m := newModel()31+ m.Configure(Settings{Config: load(), Reload: load, Keys: store,32+ Connect: func(key string) (jev.Client, error) { return &fakeJEV{}, nil }})33+ return m, path, store34+}35+36+func TestThemeFromConfig(t *testing.T) {37+ m, _, _ := configured(t, "theme = Dracula\nbogus = 1\n")38+ if m.th.Name != "Dracula" {39+ t.Errorf("theme %q", m.th.Name)40+ }41+ m, _, _ = configured(t, "theme = light:Builtin Solarized Light,dark:Dracula\n")42+ if m.th.Name != "Dracula" {43+ t.Errorf("dark terminal: %q", m.th.Name)44+ }45+ send(m, tea.BackgroundColorMsg{Color: ansi.White})46+ if m.th.Name != "Builtin Solarized Light" {47+ t.Errorf("light terminal: %q", m.th.Name)48+ }49+ m, _, _ = configured(t, "theme = No Such Theme\n")50+ if m.th.Name != "terminal" || !strings.Contains(line(m, contextLine), `no theme "No Such Theme"`) {51+ t.Errorf("unknown theme: %q, %q", m.th.Name, line(m, contextLine))52+ }53+}54+55+// With a color scheme every line is drawn out to the terminal's edge,56+// at odd widths too, and with an overlay open.57+func TestBarsFillTheWidth(t *testing.T) {58+ m, _, _ := configured(t, "theme = 1-2-3 Classic\n")59+ for _, w := range []int{61, 80, 97} {60+ send(m, tea.WindowSizeMsg{Width: w, Height: 17})61+ checkFilled(t, m, "no overlay")62+ press(t, m, "<alt+f>")63+ checkFilled(t, m, "menu open")64+ press(t, m, "<esc>")65+ }66+}67+68+// checkFilled fails unless every line is the terminal's width and every69+// cell has a background: the bars' or the scheme's.70+func checkFilled(t *testing.T, m *Model, when string) {71+ t.Helper()72+ content := m.View().Content73+ lines := strings.Split(content, "\n")74+ if len(lines) != m.height {75+ t.Fatalf("%s: %d lines, height %d", when, len(lines), m.height)76+ }77+ for i, l := range lines {78+ if got := ansi.StringWidth(l); got != m.width {79+ t.Errorf("%s, width %d: line %d is %d wide", when, m.width, i, got)80+ }81+ }82+ scr := uv.NewScreenBuffer(m.width, m.height)83+ uv.NewStyledString(content).Draw(scr, scr.Bounds())84+ for y := range m.height {85+ for x := range m.width {86+ if c := scr.CellAt(x, y); c != nil && c.Width > 0 && c.Style.Bg == nil {87+ t.Fatalf("%s, width %d: no background at %d,%d", when, m.width, x, y)88+ }89+ }90+ }91+}92+93+func TestTerminalThemeUnchanged(t *testing.T) {94+ m, _, _ := configured(t, "")95+ if strings.Contains(m.View().Content, "48;2;") {96+ t.Error("the terminal theme uses true color")97+ }98+}99+100+func TestThemePicker(t *testing.T) {101+ m, path, _ := configured(t, "# mine\ntheme = Nord\n")102+ press(t, m, "<alt+f>")103+ openSettingsItem(t, m, "Theme")104+ if m.th.Name != "nord" || !strings.Contains(screen(m), "│ nord") {105+ t.Fatalf("picker opens on the current theme: %q\n%s", m.th.Name, screen(m))106+ }107+ press(t, m, "Dracula")108+ if m.th.Name != "Dracula" {109+ t.Errorf("preview: %q", m.th.Name)110+ }111+ press(t, m, "<esc>")112+ if m.th.Name != "nord" || m.overlay != nil {113+ t.Errorf("Esc restores: %q", m.th.Name)114+ }115+ press(t, m, "<alt+f>")116+ openSettingsItem(t, m, "Theme")117+ press(t, m, "1-2-3 Classic", "<enter>")118+ if m.th.Name != "1-2-3 Classic" || !strings.Contains(line(m, contextLine), "saved in") {119+ t.Errorf("Enter keeps it: %q, %q", m.th.Name, line(m, contextLine))120+ }121+ data, _ := os.ReadFile(path)122+ if string(data) != "# mine\ntheme = 1-2-3 Classic\n" {123+ t.Errorf("config file: %q", data)124+ }125+}126+127+// openSettingsItem opens File > Settings and runs the item called title.128+func openSettingsItem(t *testing.T, m *Model, title string) {129+ t.Helper()130+ for range 30 {131+ if selectedTitle(m) == "Settings" {132+ break133+ }134+ press(t, m, "<down>")135+ }136+ press(t, m, "<right>")137+ for range 10 {138+ if selectedTitle(m) == title {139+ press(t, m, "<enter>")140+ return141+ }142+ press(t, m, "<down>")143+ }144+ t.Fatalf("no %s in Settings:\n%s", title, screen(m))145+}146+147+// selectedTitle is the highlighted item of the open menu.148+func selectedTitle(m *Model) string {149+ mo, ok := m.overlay.(*menuOverlay)150+ if !ok {151+ return ""152+ }153+ l := mo.top()154+ if l.sel < 0 {155+ return ""156+ }157+ return l.items[l.sel].label()158+}159+160+func TestAPIKeyPrompt(t *testing.T) {161+ const key = "ts-secret-9876"162+ m, _, store := configured(t, "")163+ m.runCommand("settings.jev_key")164+ press(t, m, key)165+ ctx := line(m, contextLine)166+ if strings.Contains(screen(m), key) || !strings.Contains(ctx, "TypeSafe API key: "+strings.Repeat("•", len(key))) {167+ t.Fatalf("key shown: %q", ctx)168+ }169+ if x, y, _ := m.cursorPos(); y != contextLine || x != len("TypeSafe API key: ")+len(key) {170+ t.Errorf("cursor at %d,%d", x, y)171+ }172+ press(t, m, "<enter>")173+ if got, _ := store.Get(context.Background()); got != key {174+ t.Errorf("stored %q", got)175+ }176+ if m.jev == nil || !strings.Contains(line(m, contextLine), "API key saved in the test store") {177+ t.Errorf("JEV not on: %q", line(m, contextLine))178+ }179+ if strings.Contains(screen(m), key) || m.line.text() != "" {180+ t.Error("the key is still around")181+ }182+183+ store.Err = errors.New("keychain locked")184+ m.runCommand("settings.jev_key")185+ press(t, m, "x", "<enter>")186+ if !strings.Contains(screen(m), "Couldn't save the API key: keychain locked") {187+ t.Errorf("failure not shown:\n%s", screen(m))188+ }189+}190+191+func TestKeyNeedsAStore(t *testing.T) {192+ m := newModel()193+ if commands["settings.jev_key"].available(m) || commands["settings.config_reload"].available(m) || commands["settings.config_edit"].available(m) {194+ t.Error("settings that need a config or store are available without one")195+ }196+}197+198+func TestReloadConfig(t *testing.T) {199+ m, path, _ := configured(t, "theme = nord\n")200+ os.WriteFile(path, []byte("theme = Dracula\nchart-images = false\nnotifications = false\n"), 0o600)201+ m.runCommand("settings.config_reload")202+ if m.th.Name != "Dracula" || !m.term.noImages || !m.term.noNotify || !strings.Contains(line(m, contextLine), "Config reloaded") {203+ t.Errorf("reload: %q images off %v, %q", m.th.Name, m.term.noImages, line(m, contextLine))204+ }205+ os.WriteFile(path, []byte("wat = 1\n"), 0o600)206+ m.runCommand("settings.config_reload")207+ if !strings.Contains(line(m, contextLine), "problems") {208+ t.Errorf("warnings: %q", line(m, contextLine))209+ }210+ if m.th.Name != "terminal" {211+ t.Errorf("theme back to the default: %q", m.th.Name)212+ }213+}214+215+func TestChartImagesOff(t *testing.T) {216+ m, _, _ := configured(t, "chart-images = false\n")217+ m.term.kitty = true218+ if m.term.images() || m.term.chartOptions().Image {219+ t.Error("images drawn although chart-images = false")220+ }221+}@@ -31,6 +31,7 @@ type prompt struct {
3131 label string3232 indicator string3333 fresh bool // the default is still showing; typing replaces it34+ secret bool // the answer is shown masked, e.g. an API key3435 typing bool // range prompts: the user is typing instead of pointing3536 onText func(m *Model, text string) tea.Cmd3637 onRange func(m *Model, r sheet.Rect) tea.Cmd@@ -177,6 +178,8 @@ func (p *prompt) line(m *Model) (left, right string) {
177178 return p.prefix() + m.line.text(), m.th.Muted.Render(strings.Join(p.files, " "))178179 case p.kind == promptWidth:179180 return p.prefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")181+ case p.secret:182+ return p.prefix() + mask(len(m.line.buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")180183 }181184 return p.prefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")182185 }@@ -185,6 +188,17 @@ func (p *prompt) prefix() string {
185188 return p.label + " "186189 }187190 191+// mask stands for n typed characters of a secret.192+func mask(n int) string { return strings.Repeat("•", n) }193+194+// head is the answer before the caret, as shown.195+func (p *prompt) head(m *Model) string {196+ if p.secret {197+ return mask(m.line.pos)198+ }199+ return m.line.head()200+}201+188202 func isDigits(s string) bool {189203 return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })190204 }@@ -0,0 +1,113 @@
1+package theme2+3+import (4+ "image/color"5+ "math"6+)7+8+// Contrast correction for color schemes, adapted from puzzletea's theme9+// package (same author): WCAG 2 contrast, and a foreground that falls10+// short moved just far enough to be readable.11+12+// Contrast minimums.13+const (14+ minText = 4.5 // text: WCAG AA15+ minSecondary = 3.0 // hints, muted text, borders, chart axes16+ minDisabled = 2.0 // unavailable items stay visible but recede17+ minDistinct = 1.2 // a background role against the screen, so bars and highlights show18+)19+20+func luminance(c color.Color) float64 {21+ r, g, b, _ := c.RGBA()22+ lin := func(v uint32) float64 {23+ f := float64(v) / 0xffff24+ if f <= 0.04045 {25+ return f / 12.9226+ }27+ return math.Pow((f+0.055)/1.055, 2.4)28+ }29+ return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b)30+}31+32+// contrast is the WCAG contrast ratio of two colors, from 1 to 21.33+func contrast(a, b color.Color) float64 {34+ la, lb := luminance(a), luminance(b)35+ if la < lb {36+ la, lb = lb, la37+ }38+ return (la + 0.05) / (lb + 0.05)39+}40+41+// readable returns fg, or when it has less than min contrast with bg, fg42+// moved just far enough toward toward (the scheme's text color, which43+// keeps grays gray and hues close) to reach it, or failing that toward44+// black or white.45+func readable(fg, bg, toward color.Color, min float64) color.Color {46+ if contrast(fg, bg) >= min {47+ return fg48+ }49+ if toward != nil && contrast(toward, bg) >= min {50+ return blendUntil(fg, toward, bg, min)51+ }52+ return nudge(fg, bg, min)53+}54+55+// blendUntil moves fg toward end until it has min contrast with bg; end56+// itself must have it.57+func blendUntil(fg, end, bg color.Color, min float64) color.Color {58+ lo, hi := 0.0, 1.059+ for range 24 {60+ mid := (lo + hi) / 261+ if contrast(blend(fg, end, mid), bg) >= min {62+ hi = mid63+ } else {64+ lo = mid65+ }66+ }67+ return blend(fg, end, hi)68+}69+70+// nudge blends fg toward white or black, whichever moves it away from71+// bg, just far enough to reach min contrast.72+func nudge(fg, bg color.Color, min float64) color.Color {73+ var end color.Color = color.White74+ if luminance(fg) <= luminance(bg) {75+ end = color.Black76+ }77+ if contrast(end, bg) < min {78+ if end == color.White {79+ end = color.Black80+ } else {81+ end = color.White82+ }83+ }84+ return blendUntil(fg, end, bg, min)85+}86+87+// distinct returns bg, or when it's too close to the screen to show, bg88+// moved toward toward until it stands apart.89+func distinct(bg, screen, toward color.Color) color.Color {90+ if contrast(bg, screen) >= minDistinct {91+ return bg92+ }93+ lo, hi := 0.0, 1.094+ for range 24 {95+ mid := (lo + hi) / 296+ if contrast(blend(bg, toward, mid), screen) >= minDistinct {97+ hi = mid98+ } else {99+ lo = mid100+ }101+ }102+ return blend(bg, toward, hi)103+}104+105+// blend is t of the way from a to b.106+func blend(a, b color.Color, t float64) color.Color {107+ ar, ag, ab, _ := a.RGBA()108+ br, bg, bb, _ := b.RGBA()109+ mix := func(x, y uint32) uint8 {110+ return uint8(float64(x>>8)*(1-t) + float64(y>>8)*t + 0.5)111+ }112+ return color.RGBA{R: mix(ar, br), G: mix(ag, bg), B: mix(ab, bb), A: 0xff}113+}@@ -0,0 +1,103 @@
1+package theme2+3+import (4+ "image/color"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// Fill draws a rendered line on a band's colors out to width columns:12+// every cell without a background of its own gets the band's background,13+// and every cell without a text color its foreground. Styled parts (key14+// chips, the mode indicator) keep their colors. A band with no colors15+// returns the line unchanged.16+//17+// It works on the escape sequences, so it costs one pass over the line:18+// the band's colors are set at the start and again after every SGR that19+// resets them.20+func Fill(line string, width int, band lipgloss.Style) string {21+ bg, fg := band.GetBackground(), band.GetForeground()22+ hasBg, hasFg := !isNoColor(bg), !isNoColor(fg)23+ if !hasBg && !hasFg {24+ return line25+ }26+ set := func(needBg, needFg bool) string {27+ st := ansi.NewStyle()28+ if needBg && hasBg {29+ st = st.BackgroundColor(bg)30+ }31+ if needFg && hasFg {32+ st = st.ForegroundColor(fg)33+ }34+ if len(st) == 0 {35+ return ""36+ }37+ return st.String()38+ }39+ var b strings.Builder40+ b.Grow(len(line) + 64)41+ b.WriteString(set(true, true))42+ p := ansi.NewParser()43+ var state byte44+ w := 045+ for s := line; len(s) > 0; {46+ seq, sw, n, newState := ansi.DecodeSequence(s, state, p)47+ state, s = newState, s[n:]48+ b.WriteString(seq)49+ w += sw50+ if sw == 0 && isSGR(seq, p) {51+ resetBg, resetFg := sgrResets(p.Params())52+ b.WriteString(set(resetBg, resetFg))53+ }54+ }55+ if w < width {56+ b.WriteString("\x1b[m" + set(true, true) + strings.Repeat(" ", width-w))57+ }58+ b.WriteString("\x1b[m")59+ return b.String()60+}61+62+// isSGR reports whether seq, just decoded by p, is Select Graphic63+// Rendition (ESC [ ... m).64+func isSGR(seq string, p *ansi.Parser) bool {65+ if !ansi.HasCsiPrefix(seq) {66+ return false67+ }68+ c := ansi.Cmd(p.Command())69+ return c.Final() == 'm' && c.Prefix() == 0 && c.Intermediate() == 070+}71+72+// sgrResets reports whether an SGR's parameters leave the background and73+// the foreground at the terminal's default, so the band's must be set74+// again.75+func sgrResets(params ansi.Params) (bg, fg bool) {76+ if len(params) == 0 {77+ return true, true78+ }79+ for i := 0; i < len(params); i++ {80+ switch v := params[i].Param(0); {81+ case v == 0:82+ bg, fg = true, true83+ case v == 39:84+ fg = true85+ case v == 49:86+ bg = true87+ case v >= 30 && v <= 37, v >= 90 && v <= 97:88+ fg = false89+ case v >= 40 && v <= 47, v >= 100 && v <= 107:90+ bg = false91+ case v == 38 || v == 48 || v == 58:92+ var c color.Color93+ n := ansi.ReadStyleColor(params[i:], &c)94+ if v == 38 {95+ fg = false96+ } else if v == 48 {97+ bg = false98+ }99+ i += max(n, 1) - 1100+ }101+ }102+ return bg, fg103+}@@ -0,0 +1,396 @@
1+package theme2+3+import (4+ _ "embed"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "image/color"9+ "os"10+ "path/filepath"11+ "slices"12+ "strconv"13+ "strings"14+ "sync"15+ "unicode"16+)17+18+// A Palette is a terminal color scheme, the kind every terminal ships19+// and Ghostty, kitty, iTerm2 and VHS share: the 16 ANSI colors, the20+// background and foreground, and optionally the selection color. 012's21+// roles are defined on these slots (see New), so any scheme works.22+type Palette struct {23+ Name string24+ ANSI [16]color.Color25+ Background color.Color26+ Foreground color.Color27+ Selection color.Color // may be nil28+ Dark bool29+ File string // the file it came from; empty for built-ins30+}31+32+// Terminal is the theme name that keeps the terminal's own palette.33+const Terminal = "terminal"34+35+// themesJSON is VHS's theme catalog (MIT, Charmbracelet; see NOTICE),36+// so names match VHS's Set Theme.37+//38+//go:embed themes.json39+var themesJSON []byte40+41+// classic is 1-2-3's DOS look: CGA colors on blue.42+const classic = `# 1-2-3 Classic: CGA colors on a blue screen.43+background = #0000aa44+foreground = #ffffff45+selection-background = #00aaaa46+palette = 0=#00000047+palette = 1=#aa000048+palette = 2=#00aa0049+palette = 3=#aa550050+palette = 4=#0000aa51+palette = 5=#aa00aa52+palette = 6=#00aaaa53+palette = 7=#aaaaaa54+palette = 8=#55555555+palette = 9=#ff555556+palette = 10=#55ff5557+palette = 11=#ffff5558+palette = 12=#5555ff59+palette = 13=#ff55ff60+palette = 14=#55ffff61+palette = 15=#ffffff62+`63+64+// ClassicName is the built-in 1-2-3 scheme.65+const ClassicName = "1-2-3 Classic"66+67+var builtins = sync.OnceValue(func() []Palette {68+ ps, err := parseJSON(themesJSON)69+ if err != nil {70+ panic("theme: embedded themes.json: " + err.Error())71+ }72+ c, _, err := parseText(classic)73+ if err != nil {74+ panic("theme: classic: " + err.Error())75+ }76+ c.Name = ClassicName77+ return append([]Palette{c}, ps...)78+})79+80+// Builtins returns the built-in schemes.81+func Builtins() []Palette { return builtins() }82+83+// Entry is a theme a picker can offer.84+type Entry struct {85+ Name string86+ Dark bool87+ User bool // a file in the themes directory88+}89+90+// List returns the themes available: terminal first, then the files in91+// dir (the user's themes directory, may be ""), then the built-ins, each92+// group sorted by name case-insensitively. A file with a built-in's name93+// hides the built-in.94+func List(dir string) []Entry {95+ out := []Entry{{Name: Terminal, Dark: true}}96+ seen := map[string]bool{}97+ var user []Entry98+ for _, p := range userPalettes(dir) {99+ if key := strings.ToLower(p.Name); !seen[key] {100+ seen[key] = true101+ user = append(user, Entry{Name: p.Name, Dark: p.Dark, User: true})102+ }103+ }104+ sortEntries(user)105+ var bi []Entry106+ for _, p := range Builtins() {107+ if !seen[strings.ToLower(p.Name)] {108+ bi = append(bi, Entry{Name: p.Name, Dark: p.Dark})109+ }110+ }111+ sortEntries(bi)112+ return append(append(out, user...), bi...)113+}114+115+func sortEntries(es []Entry) {116+ slices.SortStableFunc(es, func(a, b Entry) int {117+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))118+ })119+}120+121+// userPalettes reads every theme file in dir, skipping ones that don't122+// parse.123+func userPalettes(dir string) []Palette {124+ if dir == "" {125+ return nil126+ }127+ files, err := os.ReadDir(dir)128+ if err != nil {129+ return nil130+ }131+ var out []Palette132+ for _, f := range files {133+ if f.IsDir() || strings.HasPrefix(f.Name(), ".") {134+ continue135+ }136+ ps, err := ReadFile(filepath.Join(dir, f.Name()))137+ if err == nil {138+ out = append(out, ps...)139+ }140+ }141+ return out142+}143+144+// Lookup finds a scheme by name: a file in dir first (named exactly, or145+// with .json or .conf), then a built-in. Names match case-insensitively,146+// and then ignoring spaces, hyphens and underscores, so "tokyo-night"147+// finds "TokyoNight".148+func Lookup(name, dir string) (Palette, error) {149+ if dir != "" && !strings.ContainsAny(name, `/\`) && name != "." && name != ".." {150+ for _, file := range []string{name, name + ".json", name + ".conf"} {151+ if ps, err := ReadFile(filepath.Join(dir, file)); err == nil {152+ return pick(ps, name), nil153+ } else if !errors.Is(err, os.ErrNotExist) {154+ return Palette{}, err155+ }156+ }157+ }158+ all := append(userPalettes(dir), Builtins()...)159+ for _, match := range []func(string) string{strings.ToLower, squash} {160+ for _, p := range all {161+ if match(p.Name) == match(name) {162+ return p, nil163+ }164+ }165+ }166+ return Palette{}, fmt.Errorf("no theme %q (012 config themes lists them)", name)167+}168+169+// pick returns the scheme called name from a file's schemes, or the170+// first.171+func pick(ps []Palette, name string) Palette {172+ for _, p := range ps {173+ if strings.EqualFold(p.Name, name) {174+ return p175+ }176+ }177+ return ps[0]178+}179+180+func squash(s string) string {181+ return strings.Map(func(r rune) rune {182+ if r == ' ' || r == '-' || r == '_' {183+ return -1184+ }185+ return unicode.ToLower(r)186+ }, s)187+}188+189+// ReadFile reads a theme file: Ghostty's format (palette = N=#rrggbb,190+// background, foreground, selection-background), kitty's (color0 ...191+// color15, background, foreground, selection_background), or VHS's JSON192+// (one scheme or a list). A scheme without a name is named after the193+// file.194+func ReadFile(path string) ([]Palette, error) {195+ data, err := os.ReadFile(path)196+ if err != nil {197+ return nil, err198+ }199+ var ps []Palette200+ if t := strings.TrimSpace(string(data)); strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {201+ ps, err = parseJSON(data)202+ } else {203+ var p Palette204+ p, _, err = parseText(string(data))205+ ps = []Palette{p}206+ }207+ if err != nil {208+ return nil, fmt.Errorf("%s: %w", path, err)209+ }210+ for i := range ps {211+ ps[i].File = path212+ if ps[i].Name == "" {213+ ps[i].Name = strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), ".json"), ".conf")214+ }215+ }216+ return ps, nil217+}218+219+// vhsTheme is a scheme in VHS's themes.json. Puzzletea's spellings220+// (purple, cursorColor, selectionBackground) are accepted too.221+type vhsTheme struct {222+ Name string223+ Black, Red, Green, Yellow, Blue, Magenta, Cyan, White string224+ BrightBlack, BrightRed, BrightGreen, BrightYellow string225+ BrightBlue, BrightMagenta, BrightCyan, BrightWhite string226+ Purple, BrightPurple string227+ Background, Foreground, Selection string228+ SelectionBackground string229+ Meta struct {230+ IsDark *bool `json:"isDark"`231+ }232+}233+234+func parseJSON(data []byte) ([]Palette, error) {235+ var list []vhsTheme236+ if strings.HasPrefix(strings.TrimSpace(string(data)), "{") {237+ var one vhsTheme238+ if err := json.Unmarshal(data, &one); err != nil {239+ return nil, err240+ }241+ list = []vhsTheme{one}242+ } else if err := json.Unmarshal(data, &list); err != nil {243+ return nil, err244+ }245+ out := make([]Palette, 0, len(list))246+ for _, t := range list {247+ p, err := t.palette()248+ if err != nil {249+ return nil, fmt.Errorf("%s: %w", t.Name, err)250+ }251+ out = append(out, p)252+ }253+ if len(out) == 0 {254+ return nil, errors.New("no themes")255+ }256+ return out, nil257+}258+259+func (t vhsTheme) palette() (Palette, error) {260+ or := func(a, b string) string {261+ if a != "" {262+ return a263+ }264+ return b265+ }266+ slots := [16]string{t.Black, t.Red, t.Green, t.Yellow, t.Blue, or(t.Magenta, t.Purple), t.Cyan, t.White,267+ t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue, or(t.BrightMagenta, t.BrightPurple), t.BrightCyan, t.BrightWhite}268+ p := Palette{Name: t.Name}269+ var err error270+ for i, s := range slots {271+ if p.ANSI[i], err = parseHex(s); err != nil {272+ return p, fmt.Errorf("color %d: %w", i, err)273+ }274+ }275+ if p.Background, err = parseHex(t.Background); err != nil {276+ return p, fmt.Errorf("background: %w", err)277+ }278+ if p.Foreground, err = parseHex(t.Foreground); err != nil {279+ return p, fmt.Errorf("foreground: %w", err)280+ }281+ if sel := or(t.Selection, t.SelectionBackground); sel != "" {282+ p.Selection, _ = parseHex(sel)283+ }284+ p.Dark = isDark(p.Background)285+ if t.Meta.IsDark != nil {286+ p.Dark = *t.Meta.IsDark287+ }288+ return p, nil289+}290+291+// parseText parses Ghostty's or kitty's format. Unknown keys (cursor292+// colors, fonts) are ignored; they're reported in the warnings.293+func parseText(s string) (Palette, []string, error) {294+ var p Palette295+ var warnings []string296+ set := map[string]bool{}297+ for n, line := range strings.Split(s, "\n") {298+ line = strings.TrimSpace(line)299+ if line == "" || strings.HasPrefix(line, "#") {300+ continue301+ }302+ key, val, ok := strings.Cut(line, "=")303+ if !ok || strings.ContainsAny(strings.TrimSpace(key), " \t") {304+ key, val, ok = strings.Cut(line, " ") // kitty: "color0 #000000"305+ }306+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)307+ if !ok {308+ warnings = append(warnings, fmt.Sprintf("line %d: expected key = value", n+1))309+ continue310+ }311+ if err := p.setKey(key, val, set); err != nil {312+ warnings = append(warnings, fmt.Sprintf("line %d: %v", n+1, err))313+ }314+ }315+ if !set["background"] || !set["foreground"] {316+ return p, warnings, errors.New("a theme needs background and foreground")317+ }318+ for i := range p.ANSI {319+ if p.ANSI[i] == nil {320+ p.ANSI[i] = xterm[i]321+ }322+ }323+ p.Dark = isDark(p.Background)324+ return p, warnings, nil325+}326+327+// setKey applies one line of a Ghostty or kitty theme.328+func (p *Palette) setKey(key, val string, set map[string]bool) error {329+ slot := -1330+ switch {331+ case key == "palette": // Ghostty: palette = 4=#81a2be332+ i, c, ok := strings.Cut(val, "=")333+ if !ok {334+ return fmt.Errorf("palette %q: expected N=#rrggbb", val)335+ }336+ slot, val = atoiOr(strings.TrimSpace(i), -1), strings.TrimSpace(c)337+ case strings.HasPrefix(key, "color"): // kitty: color4 #81a2be338+ if slot = atoiOr(strings.TrimPrefix(key, "color"), -1); slot < 0 {339+ return nil340+ }341+ case key != "background" && key != "foreground" && key != "selection-background" && key != "selection_background":342+ return nil // cursor, selection-foreground and the like: not used343+ }344+ if slot >= 16 {345+ return nil // the 256-color cube isn't used346+ }347+ c, err := parseHex(val)348+ if err != nil {349+ return fmt.Errorf("%s: %w", key, err)350+ }351+ switch {352+ case slot >= 0:353+ p.ANSI[slot] = c354+ case key == "background":355+ p.Background = c356+ case key == "foreground":357+ p.Foreground = c358+ default:359+ p.Selection = c360+ }361+ set[key] = true362+ return nil363+}364+365+func atoiOr(s string, def int) int {366+ n, err := strconv.Atoi(s)367+ if err != nil || n < 0 {368+ return def369+ }370+ return n371+}372+373+// parseHex reads #rrggbb, rrggbb or #rgb.374+func parseHex(s string) (color.Color, error) {375+ h := strings.TrimPrefix(strings.TrimSpace(s), "#")376+ if len(h) == 3 {377+ h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})378+ }379+ v, err := strconv.ParseUint(h, 16, 32)380+ if len(h) != 6 || err != nil {381+ return nil, fmt.Errorf("%q isn't a #rrggbb color", s)382+ }383+ return color.RGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}, nil384+}385+386+// xterm is the palette used for slots a theme file leaves out.387+var xterm = [16]color.Color{388+ color.RGBA{0, 0, 0, 255}, color.RGBA{205, 0, 0, 255}, color.RGBA{0, 205, 0, 255}, color.RGBA{205, 205, 0, 255},389+ color.RGBA{0, 0, 238, 255}, color.RGBA{205, 0, 205, 255}, color.RGBA{0, 205, 205, 255}, color.RGBA{229, 229, 229, 255},390+ color.RGBA{127, 127, 127, 255}, color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{255, 255, 0, 255},391+ color.RGBA{92, 92, 255, 255}, color.RGBA{255, 0, 255, 255}, color.RGBA{0, 255, 255, 255}, color.RGBA{255, 255, 255, 255},392+}393+394+func isDark(bg color.Color) bool {395+ return contrast(bg, color.White) > contrast(bg, color.Black)396+}@@ -0,0 +1,148 @@
1+package theme2+3+import (4+ "image/color"5+ "reflect"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// barBlend is how far the menu and status bars move from the background12+// toward the text color: enough to read as a band on any scheme, little13+// enough that text on them keeps its contrast.14+const barBlend = 0.1215+16+// FromPalette draws the roles in a color scheme's colors. The roles are17+// New's, on the ANSI slots; each slot becomes the scheme's color. Then,18+// so every scheme reads well:19+//20+// - the selection uses the scheme's selection color when it has one;21+// - the menu and status bars are bands of the background moved toward22+// the text color, and the column header row is filled with the23+// header color;24+// - a background role too close to the screen's is moved apart;25+// - text that falls short of its contrast minimum (4.5:1 for text, 3:126+// for hints and lines, 2:1 for unavailable items) against its27+// background is moved toward the scheme's text color just far enough28+// to meet it, or toward black or white.29+func FromPalette(p Palette) Theme {30+ t := New(p.Dark)31+ t.Name, t.Palette = p.Name, &p32+ text := readable(p.Foreground, p.Background, nil, minText)33+ t.Screen = lipgloss.NewStyle().Background(p.Background).Foreground(text)34+ eachRole(&t, func(_ string, s *lipgloss.Style) { *s = mapStyle(*s, &p) })35+ if p.Selection != nil && contrast(p.Selection, p.Background) >= minDistinct {36+ on := text37+ if contrast(p.Background, p.Selection) > contrast(text, p.Selection) {38+ on = p.Background39+ }40+ sel := lipgloss.NewStyle().Background(p.Selection).Foreground(on)41+ t.Selection, t.HeaderSel = sel, sel42+ }43+ bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)44+ t.MenuBarRow, t.StatusBarRow = bar, bar45+ eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p) })46+ t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())47+ return t48+}49+50+// eachRole calls fn with every style role of t and its field name,51+// including the chart series.52+func eachRole(t *Theme, fn func(name string, s *lipgloss.Style)) {53+ v := reflect.ValueOf(t).Elem()54+ styleType := reflect.TypeOf(lipgloss.Style{})55+ for i := range v.NumField() {56+ f, name := v.Field(i), v.Type().Field(i).Name57+ switch {58+ case f.Type() == styleType:59+ fn(name, f.Addr().Interface().(*lipgloss.Style))60+ case f.Kind() == reflect.Array && f.Type().Elem() == styleType:61+ for j := range f.Len() {62+ fn(name, f.Index(j).Addr().Interface().(*lipgloss.Style))63+ }64+ }65+ }66+}67+68+// mapStyle replaces ANSI colors with the scheme's.69+func mapStyle(s lipgloss.Style, p *Palette) lipgloss.Style {70+ if c, ok := slot(s.GetForeground(), p); ok {71+ s = s.Foreground(c)72+ }73+ if c, ok := slot(s.GetBackground(), p); ok {74+ s = s.Background(c)75+ }76+ if c, ok := slot(s.GetUnderlineColor(), p); ok {77+ s = s.UnderlineColor(c)78+ }79+ return s80+}81+82+// slot returns the scheme's color for an ANSI color.83+func slot(c color.Color, p *Palette) (color.Color, bool) {84+ switch c := c.(type) {85+ case ansi.BasicColor:86+ return p.ANSI[c%16], true87+ case ansi.IndexedColor:88+ if c < 16 {89+ return p.ANSI[c], true90+ }91+ }92+ return nil, false93+}94+95+// minContrast is the contrast minimum for a role's text.96+func minContrast(role string) float64 {97+ switch role {98+ case "Disabled":99+ return minDisabled100+ case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101+ "Progress", "ProgressTodo", "Copied", "Series", "Match":102+ return minSecondary103+ }104+ return minText105+}106+107+// fixContrast keeps a role's background apart from the screen and its108+// text readable on it.109+func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) lipgloss.Style {110+ if role == "Screen" {111+ return s112+ }113+ bg := p.Background114+ if c := s.GetBackground(); !isNoColor(c) {115+ bg = c116+ if role != "SeriesBg" {117+ bg = distinct(c, p.Background, text)118+ s = s.Background(bg)119+ }120+ }121+ if c := s.GetForeground(); !isNoColor(c) && role != "SeriesBg" {122+ s = s.Foreground(readable(c, bg, text, minContrast(role)))123+ } else if !isNoColor(s.GetBackground()) && role != "SeriesBg" {124+ // Text on a background role with no color of its own is drawn in125+ // the screen's text color.126+ s = s.Foreground(readable(text, bg, nil, minContrast(role)))127+ }128+ return s129+}130+131+func isNoColor(c color.Color) bool {132+ _, none := c.(lipgloss.NoColor)133+ return c == nil || none134+}135+136+// Resolve returns the theme called name: the terminal theme's variant137+// for a dark or light terminal, or a scheme found by Lookup in dir. An138+// unknown scheme gives the terminal theme and the error.139+func Resolve(name string, dark bool, dir string) (Theme, error) {140+ if name == "" || name == Terminal {141+ return New(dark), nil142+ }143+ p, err := Lookup(name, dir)144+ if err != nil {145+ return New(dark), err146+ }147+ return FromPalette(p), nil148+}@@ -0,0 +1,207 @@
1+package theme2+3+import (4+ "fmt"5+ "image/color"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "charm.land/lipgloss/v2"12+ "github.com/charmbracelet/x/ansi"13+)14+15+func TestBuiltins(t *testing.T) {16+ bs := Builtins()17+ if len(bs) < 300 {18+ t.Fatalf("only %d built-in themes", len(bs))19+ }20+ if bs[0].Name != ClassicName {21+ t.Errorf("first is %q", bs[0].Name)22+ }23+ for _, name := range []string{"Dracula", "Catppuccin Mocha", "Builtin Solarized Light", "nord", "GruvboxDark", "TokyoNight"} {24+ if _, err := Lookup(name, ""); err != nil {25+ t.Error(err)26+ }27+ }28+ p, err := Lookup("tokyo-night", "")29+ if err != nil || p.Name != "TokyoNight" {30+ t.Errorf("loose match: %q %v", p.Name, err)31+ }32+ if _, err := Lookup("No Such Theme", ""); err == nil || !strings.Contains(err.Error(), "012 config themes") {33+ t.Errorf("unknown: %v", err)34+ }35+}36+37+// requireContrast fails when fg on bg is below min.38+func requireContrast(t *testing.T, theme, what string, fg, bg color.Color, min float64) {39+ t.Helper()40+ if isNoColor(fg) || isNoColor(bg) {41+ t.Errorf("%s: %s has no color (fg %v, bg %v)", theme, what, fg, bg)42+ return43+ }44+ if r := contrast(fg, bg); r < min-0.01 {45+ t.Errorf("%s: %s contrast %.2f < %.1f", theme, what, r, min)46+ }47+}48+49+// Every built-in scheme, run through the role derivation, keeps text50+// readable: on cells, on the bars, in the selection and the headers, and51+// every other role at its minimum.52+func TestEveryThemeReadable(t *testing.T) {53+ for _, p := range Builtins() {54+ th := FromPalette(p)55+ screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()56+ requireContrast(t, p.Name, "cell text", text, screenBg, minText)57+ for _, bar := range []struct {58+ name string59+ s lipgloss.Style60+ }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {61+ requireContrast(t, p.Name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), minText)62+ if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {63+ t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", p.Name, bar.name, c)64+ }65+ }66+ for _, r := range []struct {67+ name string68+ s lipgloss.Style69+ }{70+ {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},71+ {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},72+ {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},73+ } {74+ requireContrast(t, p.Name, r.name, r.s.GetForeground(), r.s.GetBackground(), minText)75+ }76+ // Muted text and hints on the bars, where the status line shows them.77+ requireContrast(t, p.Name, "muted text", th.Muted.GetForeground(), screenBg, minSecondary)78+ eachRole(&th, func(role string, s *lipgloss.Style) {79+ fg, bg := s.GetForeground(), s.GetBackground()80+ if role == "SeriesBg" || isNoColor(fg) {81+ return82+ }83+ if isNoColor(bg) {84+ bg = screenBg85+ }86+ requireContrast(t, p.Name, role, fg, bg, minContrast(role))87+ })88+ }89+}90+91+func TestSchemeUsesPaletteColors(t *testing.T) {92+ p, _ := Lookup("Dracula", "")93+ th := FromPalette(p)94+ if !th.Palette.Dark || th.Name != "Dracula" {95+ t.Errorf("%+v", th.Name)96+ }97+ // Every role is in true color: no ANSI index is left to the terminal.98+ eachRole(&th, func(role string, s *lipgloss.Style) {99+ for _, c := range []color.Color{s.GetForeground(), s.GetBackground(), s.GetUnderlineColor()} {100+ switch c.(type) {101+ case ansi.BasicColor, ansi.IndexedColor:102+ t.Errorf("%s keeps ANSI color %v", role, c)103+ }104+ }105+ })106+ if bg := th.Selection.GetBackground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) {107+ t.Errorf("selection %v, scheme's %v", bg, p.Selection)108+ }109+ // The terminal theme keeps ANSI colors and no bands.110+ term := New(true)111+ if _, ok := term.Pointer.GetBackground().(ansi.BasicColor); !ok {112+ t.Error("terminal theme lost its ANSI colors")113+ }114+ if !isNoColor(term.MenuBarRow.GetBackground()) || !isNoColor(term.Screen.GetBackground()) {115+ t.Error("terminal theme has bands")116+ }117+}118+119+func TestReadThemeFiles(t *testing.T) {120+ dir := t.TempDir()121+ ghostty := "# Ghostty\npalette = 0=#1d1f21\npalette = 4=81a2be\npalette = 200=#ffffff\nbackground = #fafafa\nforeground = #333\ncursor-color = #000000\nselection-background = #cccccc\nfont-family = x\n"122+ kitty := "# kitty\ncolor0 #000000\ncolor12 #5c5cff\nbackground #101010\nforeground #e0e0e0\nselection_background #444444\ncursor_text_color background\n"123+ vhs := `{"name": "Mine", "black": "#000000", "red": "#ff0000", "green": "#00ff00", "yellow": "#ffff00", "blue": "#0000ff",124+"purple": "#ff00ff", "cyan": "#00ffff", "white": "#eeeeee", "brightBlack": "#555555", "brightRed": "#ff5555",125+"brightGreen": "#55ff55", "brightYellow": "#ffff55", "brightBlue": "#5555ff", "brightPurple": "#ff55ff",126+"brightCyan": "#55ffff", "brightWhite": "#ffffff", "background": "#202020", "foreground": "#dddddd",127+"selectionBackground": "#333333", "meta": {"isDark": true}}`128+ os.WriteFile(filepath.Join(dir, "My Light"), []byte(ghostty), 0o644)129+ os.WriteFile(filepath.Join(dir, "kitty.conf"), []byte(kitty), 0o644)130+ os.WriteFile(filepath.Join(dir, "mine.json"), []byte(vhs), 0o644)131+ os.WriteFile(filepath.Join(dir, "broken"), []byte("palette = 1=#ff0000\n"), 0o644)132+ os.WriteFile(filepath.Join(dir, "Dracula"), []byte(ghostty), 0o644)133+134+ p, err := Lookup("My Light", dir)135+ if err != nil || p.Dark || fmt.Sprint(p.ANSI[4]) != fmt.Sprint(color.RGBA{0x81, 0xa2, 0xbe, 0xff}) ||136+ fmt.Sprint(p.Foreground) != fmt.Sprint(color.RGBA{0x33, 0x33, 0x33, 0xff}) || p.Selection == nil {137+ t.Errorf("ghostty: %+v %v", p, err)138+ }139+ if fmt.Sprint(p.ANSI[1]) != fmt.Sprint(xterm[1]) {140+ t.Error("slots left out should be xterm's")141+ }142+ p, err = Lookup("kitty", dir)143+ if err != nil || !p.Dark || fmt.Sprint(p.ANSI[12]) != fmt.Sprint(color.RGBA{0x5c, 0x5c, 0xff, 0xff}) {144+ t.Errorf("kitty: %+v %v", p, err)145+ }146+ p, err = Lookup("mine", dir)147+ if err != nil || p.Name != "Mine" || fmt.Sprint(p.ANSI[5]) != fmt.Sprint(color.RGBA{0xff, 0, 0xff, 0xff}) || p.Selection == nil {148+ t.Errorf("json: %+v %v", p, err)149+ }150+ if _, err := Lookup("broken", dir); err == nil || !strings.Contains(err.Error(), "background and foreground") {151+ t.Errorf("broken: %v", err)152+ }153+ p, _ = Lookup("dracula", dir)154+ if p.File == "" {155+ t.Error("a user file should hide the built-in of the same name")156+ }157+ names := map[string]Entry{}158+ list := List(dir)159+ for _, e := range list {160+ names[e.Name] = e161+ }162+ if list[0].Name != Terminal || !names["My Light"].User || names["My Light"].Dark || !names["Mine"].User || !names["Nord"].Dark && names["nord"].Name == "" {163+ t.Errorf("list: %v", list[:6])164+ }165+ if _, ok := names["broken"]; ok {166+ t.Error("a file that doesn't parse is listed")167+ }168+ if _, err := Lookup("../etc", dir); err == nil {169+ t.Error("path in a name")170+ }171+}172+173+func TestFill(t *testing.T) {174+ band := lipgloss.NewStyle().Background(lipgloss.Color("#102030")).Foreground(lipgloss.Color("#eeeeee"))175+ chip := lipgloss.NewStyle().Background(lipgloss.Color("#ff0000")).Render("F1")176+ line := "File " + chip + " x" + lipgloss.NewStyle().Foreground(lipgloss.Red).Render("warn") + " end"177+ got := Fill(line, 30, band)178+ if w := ansi.StringWidth(got); w != 30 {179+ t.Errorf("width %d", w)180+ }181+ if ansi.Strip(got) != ansi.Strip(line)+strings.Repeat(" ", 30-ansi.StringWidth(line)) {182+ t.Errorf("text changed: %q", ansi.Strip(got))183+ }184+ // The band's background comes back after the chip's reset.185+ after := got[strings.Index(got, "F1"):]186+ if !strings.Contains(after, "48;2;16;32;48") {187+ t.Errorf("band not restored after a reset: %q", got)188+ }189+ if Fill(line, 30, lipgloss.NewStyle()) != line {190+ t.Error("an empty band changed the line")191+ }192+ // Params of 38;2;r;g;b aren't mistaken for resets.193+ bg, fg := sgrResets(params(t, "\x1b[38;2;0;39;49m"))194+ if bg || fg {195+ t.Errorf("true color read as reset: bg %v fg %v", bg, fg)196+ }197+ bg, fg = sgrResets(params(t, "\x1b[0;31m"))198+ if !bg || fg {199+ t.Errorf("0;31: bg %v fg %v", bg, fg)200+ }201+}202+203+func params(t *testing.T, seq string) ansi.Params {204+ p := ansi.NewParser()205+ _, _, _, _ = ansi.DecodeSequence(seq, 0, p)206+ return append(ansi.Params(nil), p.Params()...)207+}@@ -16,9 +16,11 @@ import (
1616 // instead of creating styles inline, so the look stays consistent and a1717 // new theme only has to be defined here.1818 //19-// Colors are the 16 ANSI colors, so the sheet follows the user's terminal20-// palette; dark and light variants only differ where ANSI colors would21-// lose contrast.19+// Roles are defined on the 16 ANSI colors (New). The terminal theme draws20+// them as ANSI indexes, so the sheet follows the user's terminal palette,21+// with dark and light variants that only differ where ANSI colors would22+// lose contrast. A color scheme (FromPalette) draws the same roles in the23+// scheme's colors, corrected for contrast, on its own background.2224 type Theme struct {2325 Indicator lipgloss.Style // mode indicator, top right2426 Header lipgloss.Style // column letters, row numbers and the name box@@ -75,6 +77,25 @@ type Theme struct {
7577 MatchSelected lipgloss.Style // matched characters in the highlighted row7678 Cell lipgloss.Style // an ordinary cell: the base for bold, italic and underline7779 80+ // Bars: full-width bands behind the menu bar, formula bar, context81+ // line, column headers and status line. Each line is drawn on its82+ // role's background, with its foreground for text that has none, out83+ // to the terminal's edge; an empty role leaves the line as it is.84+ MenuBarRow lipgloss.Style85+ FormulaBarRow lipgloss.Style86+ ContextRow lipgloss.Style87+ ColumnHeaderRow lipgloss.Style88+ StatusBarRow lipgloss.Style89+ RowHeader lipgloss.Style // row numbers not focused, selected or hovered90+ // Screen is the background and default text color of the whole91+ // screen. Empty in the terminal theme, so the terminal's own show.92+ Screen lipgloss.Style93+94+ // Name is the theme's name, and Palette its colors: nil for the95+ // terminal theme, whose colors only the terminal knows.96+ Name string97+ Palette *Palette98+7899 // Charts: see charts.go in package ui.79100 ChartFrame lipgloss.Style // a chart's border80101 ChartSelected lipgloss.Style // the border of the selected chart and its resize handle@@ -115,7 +136,7 @@ func (t *Theme) Text(base lipgloss.Style, st sheet.Style) lipgloss.Style {
115136 return base116137 }117138 118-// New returns the dark or light variant.139+// New returns the terminal theme's dark or light variant.119140 func New(dark bool) Theme {120141 // Contrast was checked against the reference palettes in e2e: text on121142 // colored backgrounds stays at or above roughly 4.5:1.@@ -182,6 +203,9 @@ func New(dark bool) Theme {
182203 MatchSelected: accent.Bold(true).Underline(true),183204 Cell: lipgloss.NewStyle(),184205 206+ RowHeader: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),207+ Name: Terminal,208+185209 ChartFrame: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),186210 ChartSelected: lipgloss.NewStyle().Foreground(lipgloss.Cyan).Bold(true),187211 ChartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.