diff --git a/cmd/logos/flag_menu.go b/cmd/logos/flag_menu.go new file mode 100644 index 00000000..5c3b786a --- /dev/null +++ b/cmd/logos/flag_menu.go @@ -0,0 +1,287 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// flagRow is one line of a command's flag menu. A mode row is a form of the +// command on its own (`show --history`); a modifier row adjusts one mode +// (`--list` belongs to `--graph`), so ticking it alone still means its mode. +// The bare command is the mode keyed "". +type flagRow struct { + flags string // as typed, space-separated; "" for the bare command + label string + mode string // the mode this row runs under: its own flags for a mode row +} + +func (r flagRow) isMode() bool { return r.mode == r.flags } + +// mode and modifier keep the table below readable: a modifier names the mode +// it belongs to. +func mode(flags, label string) flagRow { return flagRow{flags, label, flags} } +func modifier(of, flags, label string) flagRow { + return flagRow{flags, label, of} +} + +// flagMenus are the commands that do one thing bare and others behind flags. +// The first row is the bare command and is preselected, so Enter alone does +// what the command did before it had a menu. Each flag is shown as it is +// typed, because the menu is also how a person learns the flag. A flag that +// takes a value (--project P, --since 24h) is left out: the menu cannot type +// it, and the command line still can. +var flagMenus = map[string][]flagRow{ + "show": { + mode("", "this project on one screen: checkpoint, ruled-out approaches, memories"), + modifier("", "--all", "with every item, not the first few"), + mode("--history", "checkpoint history, and any abandoned ones"), + mode("--plans", "plan-mode plans saved when ExitPlanMode was approved"), + mode("--continuity", "vault-wide: which projects checkpoint, which have gone quiet"), + mode("--projects", "auto-detected projects"), + mode("--graph", "draw this project with its checkpoints and memories"), + modifier("--graph", "--similar", "with the similar-notes edges"), + modifier("--graph", "--list", "printed as text instead of drawn"), + mode("--since-last", "catch up on what changed since you were last here"), + modifier("--since-last", "--peek", "without marking it seen"), + }, + "stats": { + mode("", "patterns in the vault: a recurring blocker, a dormant memory"), + mode("--usage", "what context packs left out, dead ends handed back, handoffs"), + modifier("--usage", "--week", "the last 7 days only"), + mode("--memory", "composition, growth, and what your memory leans on"), + }, + "review": { + mode("", "accept or reject quarantined memories, the first 20"), + modifier("", "--all", "every pending memory"), + mode("--candidates", "transcript candidates, before they become checkpoints"), + mode("--connections", "the connections a dream proposed overnight"), + mode("--stale", "rulings and memories whose files changed since they were recorded"), + mode("--auto", "what the review switch accepted for you"), + }, + "doctor": { + mode("", "health of the vault, index and hosts"), + modifier("", "--verbose", "add runtimes and tiers"), + modifier("", "--probe", "verify each model actually loads"), + mode("--integration", "prove a host can reach logos"), + mode("--report", "a paste-able bundle for a bug report"), + mode("--egress", "every address logos can contact"), + }, + "watch": { + mode("", "captures, rulings and memory changes as they happen"), + mode("--log", "every prompt, tool call and turn the host reported"), + mode("--log --projects", "which projects are being recorded"), + mode("--log --compliance", "how often a checkpoint ask was followed by one"), + }, +} + +// flagMenuRow is how a row is drawn: the flag first, as typed, then what it +// does. Modifiers are indented under their mode so the table reads as a tree. +func flagMenuRow(cmd string, r flagRow, width int) string { + switch { + case r.flags == "": + return fmt.Sprintf("%-*s %s", width, "logos "+cmd, r.label) + case r.isMode(): + return fmt.Sprintf("%-*s %s", width, r.flags, r.label) + default: + return fmt.Sprintf("%-*s %s", width, " + "+r.flags, r.label) + } +} + +// flagRuns turns the rows ticked (or, with none ticked, the one highlighted) +// into the command lines to run, one per mode, in menu order. A modifier +// ticked without its mode runs under it: ticking --list alone is a request +// for the graph as text, and reading it as nothing would throw it away. +func flagRuns(rows []flagRow, checked []bool, sel int) [][]string { + chosen := map[int]bool{} + for i, c := range checked { + if c { + chosen[i] = true + } + } + if len(chosen) == 0 { + chosen[sel] = true + } + extra := map[string][]string{} + for i, r := range rows { + if !chosen[i] { + continue + } + if _, seen := extra[r.mode]; !seen { + extra[r.mode] = nil + } + if !r.isMode() { + extra[r.mode] = append(extra[r.mode], strings.Fields(r.flags)...) + } + } + // Modes in menu order, not tick order, so the same ticks always run the + // same way. + var runs [][]string + for _, r := range rows { + if !r.isMode() { + continue + } + mods, ok := extra[r.mode] + if !ok { + continue + } + runs = append(runs, append(strings.Fields(r.flags), mods...)) + } + return runs +} + +// runFlagMenu is a bare command at a terminal: a checklist of its flags, then +// each chosen form run through runOne as if typed. The command line is +// printed before it runs, so the menu teaches the flag it saved typing. One +// form failing does not stop the rest, and the error it exits with names +// every one that failed, so two of three never reads as all three. +func runFlagMenu(cmd string, rows []flagRow, runOne func(string, []string) error) error { + width := 0 + for _, r := range rows { + w := len(r.flags) + if r.flags == "" { + w = len("logos " + cmd) + } else if !r.isMode() { + w += 4 + } + width = max(width, w) + } + lines := make([]string, len(rows)) + for i, r := range rows { + lines[i] = flagMenuRow(cmd, r, width) + } + checked := make([]bool, len(rows)) + sel, res, _, err := pickOnTerminal(menu{ + title: "logos " + cmd + " — which form?", + hint: "Space to tick, Enter to run, q to cancel", + rows: lines, checked: checked, + }) + if err != nil { + return fmt.Errorf("could not show the menu (%v); type the flags instead — `logos help %s`", err, cmd) + } + if res != picked { + fmt.Printf("Nothing run — the logos %s menu was cancelled.\n", cmd) + return nil + } + runs := flagRuns(rows, checked, sel) + var failed []string + for i, args := range runs { + line := strings.TrimSpace("logos " + cmd + " " + strings.Join(args, " ")) + if len(runs) > 1 { + if i > 0 { + fmt.Println() + } + fmt.Printf("── %d of %d: %s\n\n", i+1, len(runs), line) + } else { + fmt.Printf("── %s\n\n", line) + } + if err := runOne(cmd, args); err != nil { + if len(runs) == 1 { + return err + } + fmt.Fprintf(os.Stderr, "logos: %s: %v\n", strings.TrimPrefix(line, "logos "), err) + failed = append(failed, line) + } + } + if len(failed) > 0 { + return fmt.Errorf("%d of %d failed: %s", len(failed), len(runs), strings.Join(failed, "; ")) + } + return nil +} + +// configChoice is one value a setting can be given from the config menu. +type configChoice struct { + args []string // after `logos config ` + label string +} + +// configMenu is every setting the menu offers, in help's order, with the +// values it can be set to. Where the bare setting only reports, that report +// is first and preselected, so Enter, Enter changes nothing. deny is the +// exception: bare, it acts — it writes the marker that keeps Logos out — so +// both rows say what they do and neither is a look. key is left out: it needs +// a keychain ref typed after it. +var configMenu = []struct { + setting, label string + choices []configChoice +}{ + {"announce", "how loudly Logos reports its own work", []configChoice{ + {nil, "show the current setting"}, + {[]string{"on"}, "on — a marked receipt when Logos stores, recalls or checkpoints"}, + {[]string{"quiet"}, "quiet — the same facts, without the marker"}, + {[]string{"off"}, "off — nothing extra; the agent is still told, you are not"}, + }}, + {"auto", "what Logos does on its own, as one switch", []configChoice{ + {nil, "show the current setting"}, + {[]string{"on"}, "on"}, + {[]string{"off"}, "off"}, + {[]string{"config"}, "list the switches it covers, to choose among them"}, + }}, + {"think", "how much the model reasons before answering", []configChoice{ + {nil, "show the current setting"}, + {[]string{"off"}, "off"}, + {[]string{"low"}, "low"}, + {[]string{"medium"}, "medium"}, + {[]string{"high"}, "high"}, + }}, + {"activity", "recording the activity log, for this vault", []configChoice{ + {nil, "show the current setting"}, + {[]string{"on"}, "on — record every prompt and tool call the host reports"}, + {[]string{"off"}, "off — stop recording"}, + }}, + {"deny", "keep Logos out of this repository", []configChoice{ + {nil, "keep Logos out of this repository (writes .logos/deny)"}, + {[]string{"--undo"}, "let Logos back in (removes .logos/deny)"}, + }}, + {"prompt", "the instructions agents are given (LOGOSPROMPT.md)", nil}, +} + +// runConfigMenu is a bare `logos config` at a terminal: which setting, then +// which value. Esc in the values goes back to the settings, so a wrong pick +// costs a keypress. The first row lists the settings, which is what a bare +// config did before. +func runConfigMenu(runOne func(string, []string) error) error { + rows := []string{"list the settings"} + width := 0 + for _, s := range configMenu { + width = max(width, len(s.setting)) + } + for _, s := range configMenu { + rows = append(rows, fmt.Sprintf("%-*s %s", width, s.setting, s.label)) + } + settings := menu{title: "logos config — which setting?", hint: "Enter to choose, q to cancel", rows: rows} + for { + i, res, lines, err := pickOnTerminal(settings) + switch { + case err != nil: + return fmt.Errorf("could not show the menu (%v); type the setting instead — `logos help config`", err) + case res != picked: + fmt.Println("Nothing changed — the logos config menu was cancelled.") + return nil + case i == 0: + return runOne("config", nil) + } + s := configMenu[i-1] + var args []string + if len(s.choices) > 0 { + vals := make([]string, len(s.choices)) + for j, c := range s.choices { + vals[j] = c.label + } + j, res, valLines, err := pickOnTerminal(menu{title: "logos config " + s.setting, hint: "Enter to choose, Esc to go back, q to cancel", rows: vals}) + switch { + case err != nil: + return fmt.Errorf("could not show the menu (%v); type the setting instead — `logos help config`", err) + case res == cancelled: + fmt.Println("Nothing changed — the logos config menu was cancelled.") + return nil + case res == wentBack: + settings.sel, settings.over = i, lines+valLines + continue + } + args = s.choices[j].args + } + fmt.Printf("── %s\n\n", strings.TrimSpace("logos config "+s.setting+" "+strings.Join(args, " "))) + return runOne("config", append([]string{s.setting}, args...)) + } +} diff --git a/cmd/logos/flag_menu_test.go b/cmd/logos/flag_menu_test.go new file mode 100644 index 00000000..79d6187e --- /dev/null +++ b/cmd/logos/flag_menu_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" +) + +// screenPlays stands in for the terminal across several menus: each call +// answers the next menu with its row, how it was left, and the rows to tick. +type screenPlay struct { + sel int + res pick + ticks []int +} + +func playMenus(t *testing.T, plays ...screenPlay) *[]menu { + t.Helper() + var shown []menu + was := pickOnTerminal + pickOnTerminal = func(m menu) (int, pick, int, error) { + if len(shown) == len(plays) { + t.Fatalf("an unexpected menu opened: %q", m.title) + } + p := plays[len(shown)] + shown = append(shown, m) + for _, r := range p.ticks { + m.checked[r] = true + } + return p.sel, p.res, len(m.rows) + 1, nil + } + t.Cleanup(func() { pickOnTerminal = was }) + return &shown +} + +// recorder is a runOne that writes down each command line instead of running +// it, failing the ones named in fail. +func recorder(fail ...string) (*[]string, func(string, []string) error) { + var ran []string + return &ran, func(cmd string, args []string) error { + line := strings.TrimSpace(cmd + " " + strings.Join(args, " ")) + ran = append(ran, line) + if slices.Contains(fail, line) { + return errors.New("boom") + } + return nil + } +} + +func rowOf(t *testing.T, cmd, flags string) int { + t.Helper() + for i, r := range flagMenus[cmd] { + if r.flags == flags { + return i + } + } + t.Fatalf("logos %s has no %q row", cmd, flags) + return -1 +} + +func TestEnterWithNothingTickedRunsTheHighlightedFormAlone(t *testing.T) { + rows := flagMenus["show"] + if got := flagRuns(rows, make([]bool, len(rows)), 0); !reflect.DeepEqual(got, [][]string{{}}) { + t.Errorf("Enter on the first row ran %v, want the bare command", got) + } + got := flagRuns(rows, make([]bool, len(rows)), rowOf(t, "show", "--plans")) + if !reflect.DeepEqual(got, [][]string{{"--plans"}}) { + t.Errorf("Enter on --plans ran %v", got) + } +} + +func TestAModifierTickedWithoutItsModeRunsUnderIt(t *testing.T) { + rows := flagMenus["show"] + checked := make([]bool, len(rows)) + checked[rowOf(t, "show", "--list")] = true + if got := flagRuns(rows, checked, 0); !reflect.DeepEqual(got, [][]string{{"--graph", "--list"}}) { + t.Errorf("ticking --list alone ran %v, want show --graph --list", got) + } +} + +func TestTickedModesRunOnceEachInMenuOrderWithTheirOwnModifiers(t *testing.T) { + rows := flagMenus["doctor"] + checked := make([]bool, len(rows)) + for _, f := range []string{"--egress", "--probe", "--verbose"} { + checked[rowOf(t, "doctor", f)] = true + } + want := [][]string{{"--verbose", "--probe"}, {"--egress"}} + if got := flagRuns(rows, checked, 0); !reflect.DeepEqual(got, want) { + t.Errorf("ran %v, want %v — --probe and --verbose belong to the bare doctor, not to --egress", got, want) + } +} + +func TestTheFlagMenuRunsEveryChosenFormAndNamesTheOnesThatFailed(t *testing.T) { + playMenus(t, screenPlay{0, picked, []int{rowOf(t, "stats", "--week"), rowOf(t, "stats", "--memory"), 0}}) + ran, runOne := recorder("stats --usage --week") + err := runFlagMenu("stats", flagMenus["stats"], runOne) + if want := []string{"stats", "stats --usage --week", "stats --memory"}; !reflect.DeepEqual(*ran, want) { + t.Errorf("ran %v, want %v — one failure must not stop the rest", *ran, want) + } + if err == nil || !strings.Contains(err.Error(), "1 of 3 failed") || !strings.Contains(err.Error(), "logos stats --usage --week") { + t.Errorf("the failure is not named in the exit error: %v", err) + } +} + +func TestACancelledFlagMenuRunsNothing(t *testing.T) { + playMenus(t, screenPlay{2, cancelled, nil}) + ran, runOne := recorder() + if err := runFlagMenu("review", flagMenus["review"], runOne); err != nil || len(*ran) != 0 { + t.Errorf("a cancel ran %v, err %v", *ran, err) + } +} + +// A hook or a script runs a bare `logos show` too, and a menu there waits on +// a keypress nobody can send. +func TestABareCommandWithNoTerminalRunsAsTypedWithoutAMenu(t *testing.T) { + atTerminal(t, false) + playMenus(t) // any menu fails the test + ran, runOne := recorder() + for _, cmd := range []string{"show", "stats", "review", "doctor", "watch", "config"} { + if err := runTyped(cmd, nil, runOne); err != nil { + t.Fatal(err) + } + } + if len(*ran) != 6 { + t.Errorf("ran %v", *ran) + } +} + +func TestAnythingTypedAfterTheCommandSkipsTheMenu(t *testing.T) { + atTerminal(t, true) + playMenus(t) + ran, runOne := recorder() + if err := runTyped("show", []string{"brain"}, runOne); err != nil || !reflect.DeepEqual(*ran, []string{"show brain"}) { + t.Errorf("ran %v, err %v", *ran, err) + } +} + +func TestABareFlagCommandAtATerminalOpensItsMenu(t *testing.T) { + atTerminal(t, true) + shown := playMenus(t, screenPlay{0, picked, nil}) + ran, runOne := recorder() + if err := runTyped("doctor", nil, runOne); err != nil { + t.Fatal(err) + } + if len(*shown) != 1 || !reflect.DeepEqual(*ran, []string{"doctor"}) { + t.Errorf("menus %d, ran %v — Enter alone must still be plain doctor", len(*shown), *ran) + } +} + +// The menus are a second copy of what the commands accept, so they can drift: +// a flag renamed in its command would still be offered here and refused when +// run. Every form must pass the same flag check a typed line does, and be +// documented where a person would look it up. +func TestEveryFlagAMenuOffersIsOneItsCommandAcceptsAndDocuments(t *testing.T) { + var help strings.Builder + helpAll(&help) + documented := help.String() + reviewUsage + for cmd, rows := range flagMenus { + if rows[0].flags != "" { + t.Errorf("logos %s: the first row must be the bare command, so Enter alone changes nothing", cmd) + } + for _, r := range rows { + for _, f := range strings.Fields(r.flags) { + if !strings.Contains(documented, f) { + t.Errorf("logos %s offers %s, which no help text mentions", cmd, f) + } + } + if !r.isMode() && !slices.ContainsFunc(rows, func(m flagRow) bool { return m.isMode() && m.flags == r.mode }) { + t.Errorf("logos %s %s modifies %q, which is not a row", cmd, r.flags, r.mode) + } + } + all := make([]bool, len(rows)) + for i := range all { + all[i] = true + } + for _, args := range flagRuns(rows, all, 0) { + verb, rest, _ := surface(cmd, args) + if err := checkCommandFlags(verb, rest); err != nil { + t.Errorf("logos %s %v is refused when run: %v", cmd, args, err) + } + } + } +} + +func TestTheConfigMenuSetsTheValuePickedAndEscGoesBackToTheSettings(t *testing.T) { + think := 0 + for i, s := range configMenu { + if s.setting == "think" { + think = i + 1 + } + } + shown := playMenus(t, + screenPlay{think, picked, nil}, + screenPlay{0, wentBack, nil}, + screenPlay{think, picked, nil}, + screenPlay{3, picked, nil}, // medium + ) + ran, runOne := recorder() + if err := runConfigMenu(runOne); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*ran, []string{"config think medium"}) { + t.Errorf("ran %v", *ran) + } + if back := (*shown)[2]; back.sel != think || back.over == 0 { + t.Errorf("going back reopened the settings on row %d over %d lines; want row %d drawn over the old frames", back.sel, back.over, think) + } +} + +func TestEnterEnterInTheConfigMenuOnlyLooks(t *testing.T) { + ran, runOne := recorder() + playMenus(t, screenPlay{0, picked, nil}) + if err := runConfigMenu(runOne); err != nil || !reflect.DeepEqual(*ran, []string{"config"}) { + t.Errorf("Enter on the first row ran %v, err %v; want the list of settings", *ran, err) + } + // Every setting whose bare form only reports opens on that report. deny is + // the one whose bare form acts, and its rows must both say so. + for i, s := range configMenu { + if !slices.Contains(configSettings, s.setting) { + t.Errorf("the config menu offers %q, which is not a setting", s.setting) + } + if len(s.choices) == 0 { + continue + } + *ran = nil + playMenus(t, screenPlay{i + 1, picked, nil}, screenPlay{0, picked, nil}) + if err := runConfigMenu(runOne); err != nil { + t.Fatal(err) + } + if s.setting == "deny" { + if !strings.Contains(s.choices[0].label, "writes") { + t.Errorf("deny's first row does not say it writes the marker: %q", s.choices[0].label) + } + continue + } + if !reflect.DeepEqual(*ran, []string{"config " + s.setting}) || !strings.HasPrefix(s.choices[0].label, "show") { + t.Errorf("Enter, Enter on %s ran %v under %q; want only a look", s.setting, *ran, s.choices[0].label) + } + } +} diff --git a/cmd/logos/help.go b/cmd/logos/help.go index 1cd162f2..ba008293 100644 --- a/cmd/logos/help.go +++ b/cmd/logos/help.go @@ -56,6 +56,9 @@ Flags: -v, --version print the version Use "logos --help" for one command, "logos help all" for every form. +At a terminal, a bare logos opens a menu of commands, and a bare show, stats, +review, doctor, watch, config or import a menu of its forms; typing the flags +still works everywhere. `) } @@ -105,7 +108,9 @@ LOOKING logos stats [project] patterns already in the vault: a recurring blocker, a dormant memory logos stats --usage [project] [--week] [--usd RATE] what the budget left out of context packs, dead - ends handed back, handoffs; --week: last 7 days + ends handed back, handoffs; --week: last 7 days. + At a terminal, a dashboard that adds agent spend + and session size from Claude Code and Codex logos stats --usage off | on stop or resume counting (LOGOS_USAGE=off for one process) logos stats --memory composition, growth, and what your memory leans on logos watch captures, rulings handed back and memory changes as they happen diff --git a/cmd/logos/home_menu.go b/cmd/logos/home_menu.go new file mode 100644 index 00000000..bd3251b7 --- /dev/null +++ b/cmd/logos/home_menu.go @@ -0,0 +1,79 @@ +package main + +import ( + "errors" + "fmt" + "strings" +) + +// homeRow is one command a bare `logos` offers. Only commands that do +// something useful with nothing typed after them are here: a command that +// needs a project name or a path would open, fail on its usage line, and +// teach that the menu is a trap. search is the exception, because its row +// is a text box that takes the query. +type homeRow struct { + cmd, label string +} + +// homeRows are in the order help lists them, with resume first and +// preselected: picking up where the last agent stopped is what most people +// open logos for, so Enter alone does it. +var homeRows = []homeRow{ + {"resume", "resume pick up where the last agent left off"}, + {"search", "search search the vault — type the query"}, + {"show", "show one project on one screen"}, + {"stats", "stats patterns, usage, what memory leans on"}, + {"memory", "memory what logos knows"}, + {"review", "review accept or reject what is waiting for your yes"}, + {"import", "import bring in CLAUDE.md, Cursor rules, claude-mem, git"}, + {"watch", "watch follow what agents record as it happens"}, + {"config", "config settings"}, + {"index", "index rebuild the cache from the vault"}, + {"doctor", "doctor check the vault, the index and the hosts"}, + {"setup", "setup connect logos to the AI agents on this machine"}, + {"update", "update update logos to the latest release"}, + {"help", "help every command, with what it takes"}, +} + +// runHomeMenu is a bare `logos` at a terminal. The picked command goes +// through runTyped, so a command with forms opens its own menu next, as it +// would typed bare. The command line it stands for is printed first: the +// menu should teach the commands, not replace them. +func runHomeMenu(runOne func(string, []string) error) error { + rows := make([]string, len(homeRows)) + searchRow := 0 + for i, r := range homeRows { + rows[i] = r.label + if r.cmd == "search" { + searchRow = i + } + } + query := &menuField{row: searchRow, prompt: "search for: ", check: func(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", errors.New("type something to search for") + } + return s, nil + }} + i, res, _, err := pickOnTerminal(menu{ + title: "logos — what now?", hint: "Enter or click runs it, q to cancel", + rows: rows, field: query, mouse: true, + }) + if err != nil { + return fmt.Errorf("could not show the menu (%v); `logos help` lists the commands", err) + } + if res != picked { + fmt.Println("Nothing run — the logos menu was cancelled.") + return nil + } + cmd := homeRows[i].cmd + if cmd == "search" { + // Split the way a shell would split it unquoted, so a typed + // --answer is the flag it is on the command line. + args := strings.Fields(query.value) + fmt.Printf("→ logos search %s\n", query.value) + return runTyped(cmd, args, runOne) + } + fmt.Printf("→ logos %s\n", cmd) + return runTyped(cmd, nil, runOne) +} diff --git a/cmd/logos/home_menu_test.go b/cmd/logos/home_menu_test.go new file mode 100644 index 00000000..9e0d9c85 --- /dev/null +++ b/cmd/logos/home_menu_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "errors" + "reflect" + "strings" + "testing" +) + +func homeRowOf(t *testing.T, cmd string) int { + t.Helper() + for i, r := range homeRows { + if r.cmd == cmd { + return i + } + } + t.Fatalf("the home menu has no %s row", cmd) + return -1 +} + +func TestTheHomeMenuOpensOnResumeSoEnterAloneResumes(t *testing.T) { + if homeRows[0].cmd != "resume" { + t.Fatalf("the first home row is %q, want resume", homeRows[0].cmd) + } + atTerminal(t, true) + shown := playMenus(t, screenPlay{sel: 0, res: picked}) + ran, runOne := recorder() + if err := runHomeMenu(runOne); err != nil { + t.Fatal(err) + } + if (*shown)[0].sel != 0 || !reflect.DeepEqual(*ran, []string{"resume"}) { + t.Fatalf("sel %d ran %v, want resume", (*shown)[0].sel, *ran) + } +} + +func TestACommandPickedFromTheHomeMenuOpensItsOwnMenuNext(t *testing.T) { + atTerminal(t, true) + shown := playMenus(t, + screenPlay{sel: homeRowOf(t, "stats"), res: picked}, + screenPlay{sel: 0, res: picked}, + ) + ran, runOne := recorder() + if err := runHomeMenu(runOne); err != nil { + t.Fatal(err) + } + if len(*shown) != 2 || !strings.Contains((*shown)[1].title, "logos stats") { + t.Fatalf("menus shown %d, want the stats menu second", len(*shown)) + } + if !reflect.DeepEqual(*ran, []string{"stats"}) { + t.Fatalf("ran %v, want stats", *ran) + } +} + +func TestEveryHomeRowIsACommandThatRunsBare(t *testing.T) { + // search needs a query, which its row's text box supplies; every other + // row runs with nothing after it, so none can open on a usage error. + for _, r := range homeRows { + if r.cmd == "search" { + continue + } + if strings.Contains(r.label, "<") { + t.Fatalf("%s row asks for an argument: %q", r.cmd, r.label) + } + } +} + +func TestTheSearchRowRunsSearchOnWhatWasTyped(t *testing.T) { + atTerminal(t, true) + was := pickOnTerminal + pickOnTerminal = func(m menu) (int, pick, int, error) { + m.field.value = "flaky timeout --answer" + return m.field.row, picked, 1, nil + } + t.Cleanup(func() { pickOnTerminal = was }) + ran, runOne := recorder() + if err := runHomeMenu(runOne); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*ran, []string{"search flaky timeout --answer"}) { + t.Fatalf("ran %v", *ran) + } +} + +func TestCancellingTheHomeMenuRunsNothing(t *testing.T) { + atTerminal(t, true) + playMenus(t, screenPlay{res: cancelled}) + ran, runOne := recorder() + if err := runHomeMenu(runOne); err != nil { + t.Fatal(err) + } + if len(*ran) != 0 { + t.Fatalf("ran %v after a cancel", *ran) + } +} + +// The search row is a text box in a menu that is not a checklist. Typing into +// it and pressing Enter must end the menu on that row with the text: before, +// an answer could only tick a checklist row, and with no checklist it wrote +// into a nil slice and crashed. +func TestEnterInATextBoxOfAPlainMenuRunsThatRowWithTheText(t *testing.T) { + f := &menuField{row: 1, prompt: "search for: ", check: func(s string) (string, error) { return strings.TrimSpace(s), nil }} + var screen bytes.Buffer + sel, res, _ := runPicker(typedSlowly("j\rflaky test\r"), &screen, menu{title: "t", hint: "h", rows: []string{"a", "search", "c"}, field: f}, 80) + if res != picked || sel != 1 || f.value != "flaky test" { + t.Fatalf("sel %d res %v value %q, want row 1 picked with the text", sel, res, f.value) + } +} + +func TestAnEmptySearchKeepsTheTextBoxOpen(t *testing.T) { + f := &menuField{row: 0, prompt: "search for: ", check: func(s string) (string, error) { + if strings.TrimSpace(s) == "" { + return "", errEmptyQueryForTest + } + return s, nil + }} + var screen bytes.Buffer + _, res, _ := runPicker(typedSlowly("\r\rq"), &screen, menu{title: "t", hint: "h", rows: []string{"search"}, field: f}, 80) + if res == picked { + t.Fatal("an empty query ran a search") + } + if !strings.Contains(screen.String(), errEmptyQueryForTest.Error()) { + t.Fatalf("the reason was not shown:\n%q", screen.String()) + } +} + +var errEmptyQueryForTest = errors.New("type something to search for") + +// The wheel moved the selection while a text box was being typed into, and +// Enter then ran whichever row the wheel had reached with the typed text — +// a query typed into search could start an index rebuild. +func TestScrollingWhileTypingInTheSearchBoxStillRunsSearch(t *testing.T) { + f := &menuField{row: 1, prompt: "search for: ", check: func(s string) (string, error) { return strings.TrimSpace(s), nil }} + var screen bytes.Buffer + keys := "j\rflaky" + "\x1b[<65;5;18M\x1b[<65;5;18M" + "\r" + sel, res, _ := runPicker(keysOf(keys), &screen, menu{title: "t", hint: "h", rows: []string{"a", "search", "c", "d"}, field: f, mouse: true}, 80) + if res != picked || sel != 1 || f.value != "flaky" { + t.Fatalf("sel %d res %v value %q, want the search row picked with the text", sel, res, f.value) + } +} diff --git a/cmd/logos/import_pick.go b/cmd/logos/import_pick.go index 708a64f0..01f6662e 100644 --- a/cmd/logos/import_pick.go +++ b/cmd/logos/import_pick.go @@ -3,15 +3,19 @@ package main import ( "fmt" "os" + "path/filepath" "strings" ) // importSource is one place `logos import` reads from, as its menu offers it. // Each runs exactly what `logos import --from ` runs, so a source picked // from the menu previews and asks the same way it does when typed. +// +// dir, when set, is the folder the source reads instead of this one: the +// menu's folder row runs the rules sources with --dir. type importSource struct { - from, label string - run func(args []string) error + from, label, dir string + run func(args []string) error } // importSources is in the order help lists them. The label says where each @@ -20,34 +24,45 @@ type importSource struct { // a var: the claude-mem row calls runImport, which opens this menu. func importSources() []importSource { return []importSource{ - {claudeMemSource, "claude-mem's observations — become memories", func(args []string) error { + {from: claudeMemSource, label: "claude-mem's observations — become memories", run: func(args []string) error { return runImport(append([]string{"--from", claudeMemSource}, args...)) }}, - {claudeMDSource, "CLAUDE.md and AGENTS.md — queued for review", func(args []string) error { + {from: claudeMDSource, label: "CLAUDE.md and AGENTS.md — queued for review", run: func(args []string) error { return runImportRules(claudeMDSource, args) }}, - {cursorRulesSource, "Cursor rules — queued for review", func(args []string) error { + {from: cursorRulesSource, label: "Cursor rules — queued for review", run: func(args []string) error { return runImportRules(cursorRulesSource, args) }}, - {claudeAutoMemorySource, "Claude Code's auto memory — queued for review", func(args []string) error { + {from: claudeAutoMemorySource, label: "Claude Code's auto memory — queued for review", run: func(args []string) error { return runImportRules(claudeAutoMemorySource, args) }}, - {"transcripts", "agent transcripts — become checkpoint candidates", runIngest}, - {"git", "this repository's git history — seeds a cold vault", runBootstrap}, + {from: "transcripts", label: "agent transcripts — become checkpoint candidates", run: runIngest}, + {from: "git", label: "this repository's git history — seeds a cold vault", run: runBootstrap}, } } -// chooseImportSources asks which sources to import from. Enter with nothing -// ticked takes the highlighted row, so one source is still one keypress; -// ok=false is a cancel. +// folderSources are what a folder named in the menu is read for: the sources +// that take --dir and read files a person keeps in a folder. git is left out: +// it reads a repository's history, not what is in the folder. +var folderSources = []string{claudeMDSource, cursorRulesSource, claudeAutoMemorySource} + +// chooseImportSources asks which sources to import from. Enter or a click +// ticks a row and the last row starts the import, so several sources are as +// easy as one; the folder row is typed into, for rules kept somewhere other +// than here. ok=false is a cancel. func chooseImportSources() ([]importSource, bool, error) { sources := importSources() - rows := make([]string, len(sources)) - for i, s := range sources { - rows[i] = s.label + rows := make([]string, 0, len(sources)+1) + for _, s := range sources { + rows = append(rows, s.label) } - checked := make([]bool, len(sources)) - i, res, _, err := pickOnTerminal(menu{title: "Import from which sources?", hint: "Space to tick, Enter to import, q to cancel", rows: rows, checked: checked}) + rows = append(rows, "a folder you name — its CLAUDE.md, AGENTS.md, Cursor rules and Claude Code memory") + checked := make([]bool, len(rows)) + folder := &menuField{row: len(sources), prompt: "Folder: ", check: importFolder} + _, res, _, err := pickOnTerminal(menu{ + title: "Import from which sources?", hint: "Enter or click ticks, then Import; q cancels", + rows: rows, checked: checked, submit: "Import the ticked sources", field: folder, mouse: true, + }) if err != nil { return nil, false, err } @@ -56,17 +71,56 @@ func chooseImportSources() ([]importSource, bool, error) { return nil, false, nil } var chosen []importSource - for j, c := range checked { - if c { - chosen = append(chosen, sources[j]) + for j, s := range sources { + if checked[j] { + chosen = append(chosen, s) } } - if len(chosen) == 0 { - chosen = []importSource{sources[i]} + if checked[folder.row] && folder.value != "" { + for _, from := range folderSources { + for _, s := range sources { + if s.from == from { + s.dir = folder.value + chosen = append(chosen, s) + } + } + } } return chosen, true, nil } +// importFolder is the folder row's check: what was typed, as a folder that +// exists. A folder dragged into a terminal arrives quoted or with its spaces +// escaped, and ~ is not expanded by anything but a shell, so all three are +// undone here rather than refused. +func importFolder(typed string) (string, error) { + p := strings.TrimSpace(typed) + if len(p) >= 2 && (p[0] == '\'' || p[0] == '"') && p[len(p)-1] == p[0] { + p = p[1 : len(p)-1] + } else { + p = strings.ReplaceAll(p, "\\ ", " ") + } + if p == "" { + return "", nil + } + p, err := filepath.Abs(expandHome(p)) + if err != nil { + return "", err + } + info, err := os.Stat(p) + // The reasons leave the path out: the field above shows it, and a path + // repeated here would cut the reason off an 80-column line. + switch { + case os.IsNotExist(err): + return "", fmt.Errorf("no folder by that name") + case err != nil: + return "", err + case !info.IsDir(): + return "", fmt.Errorf("that is a file, not a folder") + } + return p, nil +} + // runImportPicked is a bare `logos import` at a terminal. The flags given // (--dry-run, --yes, --project) go to every source chosen. One source failing // does not stop the rest — they share nothing — but each failure is named as @@ -83,15 +137,24 @@ func runImportPicked(args []string) error { } var failed []string for i, s := range chosen { - if len(chosen) > 1 { + run, name := args, s.from + if s.dir != "" { + // The menu's folder replaces a --dir typed with the command: + // it was chosen later, and for this source. + run = append(dropFlag(args, "--dir"), "--dir", s.dir) + name += " --dir " + s.dir + } + // Headed even when alone: one folder source is not the only one the + // folder row runs, and an import from somewhere else must say where. + if len(chosen) > 1 || s.dir != "" { if i > 0 { fmt.Println() } - fmt.Printf("── %d of %d: logos import --from %s\n\n", i+1, len(chosen), s.from) + fmt.Printf("── %d of %d: logos import --from %s\n\n", i+1, len(chosen), name) } - if err := s.run(args); err != nil { - fmt.Fprintf(os.Stderr, "logos: import --from %s: %v\n", s.from, err) - failed = append(failed, s.from) + if err := s.run(run); err != nil { + fmt.Fprintf(os.Stderr, "logos: import --from %s: %v\n", name, err) + failed = append(failed, name) } } if len(failed) > 0 { diff --git a/cmd/logos/import_pick_test.go b/cmd/logos/import_pick_test.go index c8ff4217..db5f1c3c 100644 --- a/cmd/logos/import_pick_test.go +++ b/cmd/logos/import_pick_test.go @@ -83,14 +83,6 @@ func TestTheImportMenuImportsEveryTickedSourceInOrder(t *testing.T) { } } -func TestEnterWithNothingTickedImportsTheHighlightedSource(t *testing.T) { - ticks(t, 5, picked) - chosen, ok, err := chooseImportSources() - if err != nil || !ok || len(chosen) != 1 || chosen[0].from != "git" { - t.Fatalf("chose %v ok=%v err=%v, want only git", fromOf(chosen), ok, err) - } -} - func TestCancellingTheImportMenuImportsNothing(t *testing.T) { for _, res := range []pick{cancelled, wentBack} { ticks(t, 0, res, 0, 1) @@ -172,3 +164,167 @@ func TestIngestConsentTypedRightAfterTheMenuIsGranted(t *testing.T) { t.Fatalf("the y typed after the menu's Enter did not grant consent: %v", err) } } + +// typedSlowly delivers keys a byte per read, as a person types them, so every +// key draws its own frame. +func typedSlowly(s string) *bufio.Reader { + return bufio.NewReader(iotest.OneByteReader(strings.NewReader(s))) +} + +// importMenu is the shape of the import menu, small: two rows, the second a +// folder field, and the row that starts the import. +func importMenu(checked []bool, f *menuField) menu { + return menu{title: "t", hint: "h", rows: []string{"a", "folder"}, checked: checked, submit: "Import", field: f} +} + +func TestEnterTicksARowAndOnlyTheLastRowStartsTheImport(t *testing.T) { + checked := make([]bool, 3) + var screen bytes.Buffer + m := menu{title: "t", hint: "h", rows: []string{"a", "b", "c"}, checked: checked, submit: "Import"} + if _, res, _ := runPicker(keysOf("\rjj\rk\r\rjj\r"), &screen, m, 80); res != picked { + t.Fatalf("menu ended %v, want picked", res) + } + if want := []bool{true, false, true}; checked[0] != want[0] || checked[1] != want[1] || checked[2] != want[2] { + t.Errorf("ticks %v, want %v: Enter on a row ticks and unticks it", checked, want) + } +} + +func TestTheLastRowRefusesToStartWithNothingTicked(t *testing.T) { + checked := make([]bool, 1) + var screen bytes.Buffer + m := menu{title: "t", hint: "h", rows: []string{"a"}, checked: checked, submit: "Import"} + // Input ends after the refused Enter: had it been taken, the menu would + // have ended picked rather than with nobody left to choose. + if _, res, _ := runPicker(keysOf("j\r"), &screen, m, 80); res != cancelled { + t.Fatalf("menu ended %v, want it still open when the input ran out", res) + } + if !strings.Contains(screen.String(), "Tick at least one row first.") { + t.Errorf("the refusal is not said:\n%q", screen.String()) + } +} + +func TestAClickOnARowTicksItAndAClickOnTheLastRowImports(t *testing.T) { + checked := make([]bool, 2) + var screen bytes.Buffer + m := menu{title: "t", hint: "h", rows: []string{"a", "b"}, checked: checked, submit: "Import", mouse: true} + // The first frame is four lines and leaves the cursor on row 20, so its + // title is on row 16, "b" on 18 and the last row on 19. A release (m) + // follows each press and must not count as a second click. + keys := "\x1b[20;1R" + "\x1b[<0;5;18M\x1b[<0;5;18m" + "\x1b[<0;5;16M" + "\x1b[<0;5;19M\x1b[<0;5;19m" + if _, res, _ := runPicker(keysOf(keys), &screen, m, 80); res != picked { + t.Fatalf("menu ended %v, want picked by the click on the last row", res) + } + if checked[0] || !checked[1] { + t.Errorf("ticks %v, want only the clicked row", checked) + } + if !strings.Contains(screen.String(), "\x1b[6n") { + t.Error("the menu never asked where it was drawn, so it cannot place a click") + } +} + +func TestAMenuWithoutTheMouseDoesNotAskWhereItIsDrawn(t *testing.T) { + var screen bytes.Buffer + runPicker(keysOf("\r"), &screen, menu{title: "t", hint: "h", rows: []string{"a"}}, 80) + if strings.Contains(screen.String(), "\x1b[6n") { + t.Error("a menu without mouse asked for the cursor, and its answer would reach the next prompt") + } +} + +func TestTheFolderRowTakesATypedFolderAndTicksIt(t *testing.T) { + dir := t.TempDir() + checked := make([]bool, 2) + f := &menuField{row: 1, prompt: "Folder: ", check: importFolder} + var screen bytes.Buffer + // q and j are typed into the field, not taken as keys, and Backspace + // takes them out again. + keys := "j\r" + dir + "qj\x7f\x7f\r" + "j\r" + if _, res, _ := runPicker(typedSlowly(keys), &screen, importMenu(checked, f), 300); res != picked { + t.Fatalf("menu ended %v, want picked", res) + } + if f.value != dir || !checked[1] { + t.Errorf("field %q ticked %v, want %q ticked", f.value, checked[1], dir) + } + if !strings.Contains(screen.String(), "folder: "+dir) { + t.Errorf("the row does not show the folder it will read:\n%q", screen.String()) + } +} + +func TestAFolderThatDoesNotExistKeepsTheFieldOpenAndSaysSo(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope") + checked := make([]bool, 2) + f := &menuField{row: 1, prompt: "Folder: ", check: importFolder} + var screen bytes.Buffer + // The j after the refused Enter is typed into the field, which is how + // it shows the field stayed open. + if _, res, _ := runPicker(typedSlowly("j\r"+missing+"\rj"), &screen, importMenu(checked, f), 80); res != cancelled { + t.Fatalf("menu ended %v, want it still open", res) + } + if f.value != "" || checked[1] { + t.Errorf("a missing folder was taken: %q ticked %v", f.value, checked[1]) + } + // At 80 columns the path is longer than the line: the field shows its + // end, where the typing is, and the whole refusal fits under it. + if !strings.Contains(screen.String(), " no folder by that name — fix it, or Esc to leave the field\r\n") || !strings.Contains(screen.String(), "/nopej█\r\n") { + t.Errorf("the refusal or the open field's end is not on screen:\n%q", screen.String()) + } +} + +func TestEscLeavesTheFolderFieldWithoutLeavingTheMenu(t *testing.T) { + checked := make([]bool, 2) + f := &menuField{row: 1, prompt: "Folder: ", check: importFolder} + var screen bytes.Buffer + if _, res, _ := runPicker(keysOf("j\rabc\x1b"), &screen, importMenu(checked, f), 80); res != cancelled { + t.Fatalf("menu ended %v, want it open until the input ran out", res) + } + if f.value != "" || checked[1] { + t.Errorf("Esc kept what was typed: %q ticked %v", f.value, checked[1]) + } +} + +func TestAFolderInTheImportMenuRunsEachRulesSourceOnIt(t *testing.T) { + dir := t.TempDir() + was := pickOnTerminal + pickOnTerminal = func(m menu) (int, pick, int, error) { + m.checked[0] = true + m.field.value, m.checked[m.field.row] = dir, true + return len(m.rows), picked, 0, nil + } + t.Cleanup(func() { pickOnTerminal = was }) + chosen, ok, err := chooseImportSources() + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if got := strings.Join(fromOf(chosen), " "); got != "claude-mem claude-md cursor-rules claude-auto-memory" { + t.Fatalf("chose %q, want claude-mem then the three rules sources", got) + } + if chosen[0].dir != "" { + t.Errorf("claude-mem was given the folder %q, which it was not ticked for", chosen[0].dir) + } + for _, s := range chosen[1:] { + if s.dir != dir { + t.Errorf("%s reads %q, want the folder %q", s.from, s.dir, dir) + } + } +} + +func TestAFolderDraggedIntoTheTerminalIsTakenAsTyped(t *testing.T) { + dir := filepath.Join(t.TempDir(), "my notes") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, typed := range []string{dir, "'" + dir + "'", `"` + dir + `"`, strings.ReplaceAll(dir, " ", `\ `), " " + dir + " "} { + if got, err := importFolder(typed); err != nil || got != dir { + t.Errorf("%q gave %q, %v; want %q", typed, got, err, dir) + } + } + file := filepath.Join(dir, "f.md") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + if _, err := importFolder(file); err == nil || !strings.Contains(err.Error(), "not a folder") { + t.Errorf("a file was taken as a folder: %v", err) + } + if got, err := importFolder(" "); err != nil || got != "" { + t.Errorf("an empty field gave %q, %v; want it cleared", got, err) + } +} diff --git a/cmd/logos/main.go b/cmd/logos/main.go index 9018fb19..a890ab20 100644 --- a/cmd/logos/main.go +++ b/cmd/logos/main.go @@ -56,7 +56,17 @@ func unknownCommand(name string) { func main() { start(os.Stderr, setup.Hosts()) if len(os.Args) < 2 { - usage() + // A person at a terminal gets the commands to choose from; a script + // or a hook still gets help on stderr and exit 2, as it always has. + if !pickerAllowed(false) { + usage() + } + migrateHint(os.Stderr) + if err := runHomeMenu(run); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return } cmd := os.Args[1] if cmd != "migrate" { @@ -80,18 +90,49 @@ func main() { } noteRenamed(os.Stderr, cmd, args) + + if err := runTyped(cmd, args, run); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +// runTyped is the command line as typed. Nothing after a command whose other +// modes hide behind flags, from a person at a terminal, shows them those +// modes; anything typed after it, or no terminal — a hook, a script, a pipe — +// runs exactly what was typed. The menu hands each choice to runOne, so a +// picked form goes through the same dispatch as a typed one. +func runTyped(cmd string, args []string, runOne func(string, []string) error) error { + if len(args) > 0 || !pickerAllowed(false) { + return runOne(cmd, args) + } + if rows, ok := flagMenus[cmd]; ok { + return runFlagMenu(cmd, rows, runOne) + } + if cmd == "config" { + return runConfigMenu(runOne) + } + return runOne(cmd, args) +} + +// run is one command line after the program name, as main has always +// dispatched it. A failure comes back — with its --json block already printed +// for a script that asked for one — for the caller to report. +func run(cmd string, args []string) (err error) { cmd, args, form := surface(cmd, args) rest := strings.Join(args, " ") + defer func() { + if err != nil { + printJSONFailure(cmd, args, err) + } + }() if spec, ok := commandFlags[cmd]; ok { if err := checkFlags(form, args, spec); err != nil { - printJSONFailure(cmd, args, err) - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) + return err } } - var err error switch { case cmd == "version", cmd == "--version", cmd == "-v": fmt.Printf("logos %s %s/%s %s\n", version, runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -250,12 +291,7 @@ func main() { default: unknownCommand(cmd) } - - if err != nil { - printJSONFailure(cmd, args, err) - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } + return err } func env(key, def string) string { diff --git a/cmd/logos/picker.go b/cmd/logos/picker.go index cba45502..d629036c 100644 --- a/cmd/logos/picker.go +++ b/cmd/logos/picker.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "os" + "strings" + "unicode/utf8" "golang.org/x/term" @@ -118,11 +120,33 @@ func pickAgent(agents []launcher) (*launcher, pick, int, error) { // checked, when set, makes it a checklist: Space ticks the selected row, and // the ticks are left in checked for the caller, which shares its backing // array with the copy the picker was given. +// +// submit, set on a checklist, adds a last row that ends the menu, and Enter +// or a click on any other row ticks it instead. That is opt-in: in every +// other menu Enter runs the highlighted row, and the flag menus and resume +// depend on it. +// +// field makes one row a text box that Enter or a click opens, as the "other" +// answer does in an agent's question menu. mouse turns on click reporting +// while the menu is up. type menu struct { title, hint string rows []string sel, over int checked []bool + submit string + field *menuField + mouse bool +} + +// menuField is a row typed into. check cleans what was typed or says what is +// wrong with it; the menu shows the reason and stays open on the text, so a +// mistyped folder is fixed in place rather than discovered after the run. +// value is left set for the caller, and the row ticked when it is not empty. +type menuField struct { + row int + prompt, value string + check func(string) (string, error) } // keyboard is stdin, read through one buffer for every menu. A fast typist's @@ -149,6 +173,13 @@ var pickOnTerminal = func(m menu) (int, pick, int, error) { return 0, cancelled, 0, err } defer term.Restore(fd, old) + if m.mouse { + // SGR reporting (1006), because the old encoding cannot say a row + // past 223. Off again before the shell gets the terminal back: left + // on, every click in it after logos exits types escape codes. + fmt.Print("\x1b[?1000h\x1b[?1006h") + defer fmt.Print("\x1b[?1000l\x1b[?1006l") + } i, res, lines := runPicker(keyboard, os.Stdout, m, width) return i, res, lines, nil } @@ -173,7 +204,35 @@ const pickerWindow = 10 // raw-mode terminal does not turn "\n" into a carriage return. func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, int) { rows := m.rows - sel, top, up, drawn := min(max(m.sel, 0), len(rows)-1), 0, m.over, 0 + n := len(rows) + if m.submit != "" { + n++ // the submit row, drawn last and never ticked + } + sel, top, up, drawn := min(max(m.sel, 0), n-1), 0, m.over, 0 + editing, typed, msg := false, []byte(nil), "" + // A click says where on the screen it landed, not which row, so each + // frame asks the terminal where its cursor ended up. pending is the + // height of every frame still waiting on its answer, oldest first, and + // titleAt the screen row the newest answered frame put its title on. + var pending []int + titleAt := 0 + label := func(i int) string { + switch { + case i == len(rows): + return m.submit + case m.field != nil && i == m.field.row && editing: + // The end of what is typed, not the start, when it is too long: + // a path grows on the right, and cut there the cursor is gone. + r := []rune(string(typed) + "█") + if room := width - 1 - 6 - utf8.RuneCountInString(m.field.prompt); len(r) > room && room > 1 { + r = append([]rune("…"), r[len(r)-room+1:]...) + } + return m.field.prompt + string(r) + case m.field != nil && i == m.field.row && m.field.value != "": + return rows[i] + ": " + m.field.value + } + return rows[i] + } draw := func() { if up > 0 { fmt.Fprintf(out, "\x1b[%dA", up) @@ -187,22 +246,25 @@ func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, i // Every line is cut to the width, the title too: the redraw moves up // one row per line, and a line that wraps leaves debris behind. lines := []string{text.Ellipsize(m.title+" ("+m.hint+")", width-1)} - for i := top; i < len(rows) && i < top+pickerWindow; i++ { + for i := top; i < n && i < top+pickerWindow; i++ { mark := " " if i == sel { mark = "> " } - if m.checked != nil { + if m.checked != nil && i < len(rows) { if m.checked[i] { mark += "[x] " } else { mark += "[ ] " } } - lines = append(lines, text.Ellipsize(mark+rows[i], width-1)) + lines = append(lines, text.Ellipsize(mark+label(i), width-1)) } - if len(rows) > pickerWindow { - lines = append(lines, fmt.Sprintf(" (%d of %d)", sel+1, len(rows))) + if n > pickerWindow { + lines = append(lines, fmt.Sprintf(" (%d of %d)", sel+1, n)) + } + if msg != "" { + lines = append(lines, text.Ellipsize(" "+msg, width-1)) } for _, l := range lines { fmt.Fprintf(out, "\x1b[2K%s\r\n", l) @@ -212,6 +274,142 @@ func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, i // otherwise leave the old one's last lines under it. fmt.Fprint(out, "\x1b[J") up, drawn = len(lines), len(lines) + if m.mouse { + fmt.Fprint(out, "\x1b[6n") + pending = append(pending, drawn) + } + } + // settle reads the answers to frames drawn just before the menu ended. + // Left in the buffer, an answer is the first thing the next prompt reads, + // and "\x1b[14;1Ry" is not a yes. Enter or Ctrl-C ends the wait, so a + // terminal that never answers costs a keypress, not a hang. + settle := func() { + for len(pending) > 0 { + b, err := in.ReadByte() + if err != nil || b == '\r' || b == 3 { + return + } + if b == 0x1b { + if next, err := in.ReadByte(); err == nil && next == '[' { + if _, final := readCSI(in); final == 'R' { + pending = pending[1:] + } + } + } + } + } + // activate is Enter on the highlighted row, or a click on it; done is + // whether the menu ends with it. + activate := func() (done bool) { + switch { + case m.submit != "" && sel == len(rows): + for _, c := range m.checked { + if c { + return true + } + } + msg = "Tick at least one row first." + case m.field != nil && sel == m.field.row: + editing, typed = true, []byte(m.field.value) + case m.submit != "": + m.checked[sel] = !m.checked[sel] + default: + return true + } + return false + } + // accept checks what was typed; a refusal keeps the field open on it. In + // a checklist the answer ticks its row and the menu stays up for the + // rest; anywhere else the row was the choice, so an answer ends the menu + // the way Enter on any other row does. + accept := func() (done bool) { + v, err := m.field.check(string(typed)) + if err != nil { + msg = err.Error() + " — fix it, or Esc to leave the field" + return false + } + m.field.value, editing = v, false + if m.checked != nil { + m.checked[m.field.row] = v != "" + return false + } + return v != "" + } + // escape reads what follows an ESC and handles it. It reports whether + // the screen changed, and how the menu ends if it does. + escape := func() (dirty bool, end pick, ended bool) { + // An arrow arrives as ESC [ A (or ESC O A in application mode) in + // one write, so its tail is already buffered; an ESC with nothing + // after it is the key itself. + lone := in.Buffered() == 0 + if !lone { + next, _ := in.Peek(1) + lone = next[0] != '[' && next[0] != 'O' + } + if lone { + if editing { + editing = false + return true, 0, false + } + return false, wentBack, true + } + intro, _ := in.ReadByte() + var params string + var final byte + if intro == 'O' { + final, _ = in.ReadByte() + } else { + params, final = readCSI(in) + } + switch { + case final == 'R' && len(pending) > 0: + var r, c int + if _, err := fmt.Sscanf(params, "%d;%d", &r, &c); err == nil { + titleAt = r - pending[0] + } + pending = pending[1:] + return false, 0, false + case final == 'M' && strings.HasPrefix(params, "<"): + var b, x, y int + if _, err := fmt.Sscanf(params, "<%d;%d;%d", &b, &x, &y); err != nil { + return false, 0, false + } + switch { + case editing && (b == 64 || b == 65): + // The typed text belongs to the field's row; a wheel that + // moved the selection would hand it to whichever row Enter + // then lands on. + return false, 0, false + case b == 64: + sel = max(sel-1, 0) + case b == 65: + sel = min(sel+1, n-1) + case b == 0: + k := y - titleAt + if titleAt == 0 || k < 1 || k > pickerWindow || top+k-1 >= n { + return false, 0, false // the title, or off the menu + } + row := top + k - 1 + if editing { + if row == m.field.row { + return false, 0, false + } + editing = false // a click elsewhere leaves the field as it was + } + sel, msg = row, "" + if activate() { + return true, picked, true + } + } + return true, 0, false + case editing: + return false, 0, false + case final == 'A': + sel = max(sel-1, 0) + case final == 'B': + sel = min(sel+1, n-1) + } + return true, 0, false } draw() for { @@ -219,42 +417,79 @@ func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, i if err != nil { return 0, cancelled, drawn // input closed: nobody is there to choose } + dirty := true + if b != 0x1b { + msg = "" + } switch { + case b == 3 || b == 4: // Ctrl-C, Ctrl-D, in the field as anywhere + return 0, cancelled, drawn + case b == 0x1b: + d, end, ended := escape() + if ended { + if end != picked { + return 0, end, drawn + } + settle() + return sel, picked, drawn + } + dirty = d + case editing && (b == '\r' || b == '\n'): + if accept() { + settle() + return sel, picked, drawn + } + case editing && (b == 0x7f || b == 8): // Backspace takes a whole character + if len(typed) > 0 { + _, size := utf8.DecodeLastRune(typed) + typed = typed[:len(typed)-size] + } + case editing && b >= 0x20: + typed = append(typed, b) + case editing: case b == '\r' || b == '\n': - return sel, picked, drawn - case b == 'q' || b == 3 || b == 4: // q, Ctrl-C, Ctrl-D + if activate() { + settle() + return sel, picked, drawn + } + case b == 'q': return 0, cancelled, drawn case b == 'k': sel = max(sel-1, 0) case b == 'j': - sel = min(sel+1, len(rows)-1) - case b == ' ' && m.checked != nil: + sel = min(sel+1, n-1) + case b == ' ' && m.field != nil && sel == m.field.row && m.field.value == "": + activate() // nothing to tick until a folder is named + case b == ' ' && m.checked != nil && sel < len(rows): m.checked[sel] = !m.checked[sel] - case b == 0x1b: - // An arrow arrives as ESC [ A (or ESC O A in application mode) - // in one write, so its tail is already buffered; an ESC with - // nothing after it is the key itself, which goes back. - if in.Buffered() == 0 { - return 0, wentBack, drawn - } - if next, _ := in.Peek(1); next[0] != '[' && next[0] != 'O' { - return 0, wentBack, drawn - } - in.Discard(1) - switch c, _ := in.ReadByte(); c { - case 'A': - sel = max(sel-1, 0) - case 'B': - sel = min(sel+1, len(rows)-1) - } } - // Once per batch of keys, not per key: held arrows redraw once. - if in.Buffered() == 0 { + // Once per batch of keys, not per key: held arrows redraw once. A + // cursor report changes nothing, and redrawing for it would ask + // again, forever. + if dirty && in.Buffered() == 0 { draw() } } } +// readCSI reads the rest of a control sequence after ESC [: its parameters, +// then the byte that ends it. A click and a cursor report are longer than the +// one byte an arrow needs, and reading one byte left the rest to be taken as +// keys. +func readCSI(in *bufio.Reader) (string, byte) { + var params []byte + for { + c, err := in.ReadByte() + if err != nil { + return string(params), 0 + } + if c >= 0x40 && c <= 0x7e { + return string(params), c + } + params = append(params, c) + } +} + // cancelledPick is said when the picker is dismissed, so a cancel is never // mistaken for an empty project. func cancelledPick() { diff --git a/cmd/logos/review.go b/cmd/logos/review.go index 7d410273..1cc61af8 100644 --- a/cmd/logos/review.go +++ b/cmd/logos/review.go @@ -8,6 +8,9 @@ import ( "strconv" "strings" "time" + "unicode" + + "golang.org/x/term" "github.com/Coder8124/logos/internal/index" "github.com/Coder8124/logos/internal/memory" @@ -111,8 +114,13 @@ func runReview(args []string) error { return nil } - in := bufio.NewReader(os.Stdin) - accepted, rejected, skipped, _ := reviewMemories(ix, in, memories, all) + answer := lineAnswers(bufio.NewReader(os.Stdin)) + if pickerAllowed(false) { + // keyboard, not a reader of its own: reached from a menu, the key + // typed right after it may already sit in keyboard's buffer. + answer = keyAnswers(keyboard, rawStdin) + } + accepted, rejected, skipped, _ := reviewMemories(ix, answer, memories, all) // No "run `logos index`" after an accept: Accept writes the database and the // vault together, so the memory is recallable already. @@ -125,7 +133,7 @@ func runReview(args []string) error { // flushes it to the vault; reject discards it for good. There is no // "evidence" view here the way rollup's now-deleted proposal review had one — // a memory is already the atomic fact, not a summary of events behind it. -func reviewMemories(ix *index.Index, in *bufio.Reader, pending []memory.Memory, all bool) (accepted, rejected, skipped int, quit bool) { +func reviewMemories(ix *index.Index, answer reviewAnswer, pending []memory.Memory, all bool) (accepted, rejected, skipped int, quit bool) { fmt.Printf("%d pending memor%s · [a]ccept [r]eject [s]kip [q]uit\n\n", len(pending), pluralY(len(pending))) for i, m := range pending { @@ -139,13 +147,13 @@ func reviewMemories(ix *index.Index, in *bufio.Reader, pending []memory.Memory, for { fmt.Print(" > ") - line, err := in.ReadString('\n') + key, err := answer() if err != nil { fmt.Println() return accepted, rejected, skipped, true } - switch strings.TrimSpace(strings.ToLower(line)) { + switch key { case "a", "y": if err := memory.Accept(ix.DB, m.ID); err != nil { fmt.Printf(" ! %v\n", err) @@ -174,6 +182,96 @@ func reviewMemories(ix *index.Index, in *bufio.Reader, pending []memory.Memory, return accepted, rejected, skipped, false } +// reviewAnswer reads the answer to one review prompt, lower-cased, with "" +// for a bare Enter. +type reviewAnswer func() (string, error) + +// lineAnswers is a review piped in or scripted: one answer per line, as it +// has always been read, so `printf 'a\nr\n' | logos review` keeps working. +func lineAnswers(in *bufio.Reader) reviewAnswer { + return func() (string, error) { + line, err := in.ReadString('\n') + return strings.TrimSpace(strings.ToLower(line)), err + } +} + +// rawStdin puts the terminal in raw mode and returns what undoes it. +func rawStdin() (func(), error) { + fd := int(os.Stdin.Fd()) + old, err := term.MakeRaw(fd) + if err != nil { + return nil, err + } + return func() { term.Restore(fd, old) }, nil +} + +// keyAnswers is a review at a terminal: one keypress answers, no Enter. +// Twenty memories was forty keystrokes, and the Enter carried nothing. +// +// Raw mode is held for exactly one key, so what review prints between keys +// goes through the terminal's own line handling and stays where it belongs. +// The key is echoed, because raw mode does not, and a scrolled-back review +// should still show what each memory got. Keys that answer nothing are +// swallowed rather than re-prompted. An arrow key is ESC [ A, and read a +// byte at a time its "A" would accept a memory nobody chose — so a whole +// escape sequence is read and dropped. Ctrl-C and Ctrl-D quit with the +// tally, as q does: raw mode turns Ctrl-C into a byte, not a signal. +// +// A terminal that refuses raw mode is said once on stderr and the review goes +// on reading lines: returned as an error, it read as the end of input, and +// review quit with "0 accepted" and exit 0 before anyone had answered. +func keyAnswers(in *bufio.Reader, raw func() (func(), error)) reviewAnswer { + var lines reviewAnswer + return func() (string, error) { + if lines != nil { + return lines() + } + restore, err := raw() + if err != nil { + fmt.Fprintf(os.Stderr, "logos: could not read single keys (%v) — type each answer and press Enter\n", err) + lines = lineAnswers(in) + return lines() + } + for { + b, err := in.ReadByte() + if err != nil { + restore() + return "", err + } + key := "" + switch b = byte(unicode.ToLower(rune(b))); { + case b == 0x1b: + // A lone Esc arrives by itself; a sequence arrives whole, + // as ESC [ … or, in application cursor mode, ESC O A. + if in.Buffered() > 0 { + switch next, _ := in.ReadByte(); next { + case '[': + readCSI(in) + case 'O': + in.ReadByte() + } + } + continue + case b == 3 || b == 4: + key = "q" + case b == '\r' || b == '\n': + key = "" + case strings.IndexByte("ayrnsq", b) >= 0: + key = string(b) + default: + continue + } + restore() + if key == "" { + fmt.Println("s") + } else { + fmt.Println(key) + } + return key, nil + } + } +} + func pluralY(n int) string { if n == 1 { return "y" diff --git a/cmd/logos/review_keys_test.go b/cmd/logos/review_keys_test.go new file mode 100644 index 00000000..af9a3b9b --- /dev/null +++ b/cmd/logos/review_keys_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "bufio" + "errors" + "strings" + "testing" +) + +// fakeRaw stands in for the terminal's raw mode and counts how often it was +// entered and left, so a test can see the terminal is always given back. +func fakeRaw(entered, restored *int) func() (func(), error) { + return func() (func(), error) { + *entered++ + return func() { *restored++ }, nil + } +} + +func answersTo(t *testing.T, keys string, n int) []string { + t.Helper() + var entered, restored int + answer := keyAnswers(bufio.NewReader(strings.NewReader(keys)), fakeRaw(&entered, &restored)) + var got []string + captureStdout(t, func() { + for range n { + k, err := answer() + if err != nil { + t.Fatalf("answer %d: %v", len(got)+1, err) + } + got = append(got, k) + } + }) + if entered != n || restored != n { + t.Fatalf("raw mode entered %d, restored %d, for %d answers", entered, restored, n) + } + return got +} + +func TestAtATerminalOneKeypressAnswersAReviewPromptWithoutEnter(t *testing.T) { + got := answersTo(t, "arsY", 4) + if strings.Join(got, ",") != "a,r,s,y" { + t.Fatalf("got %q", got) + } +} + +func TestEnterAloneSkipsInASingleKeyReview(t *testing.T) { + if got := answersTo(t, "\r", 1); got[0] != "" { + t.Fatalf("Enter answered %q, want a skip", got[0]) + } +} + +func TestAnArrowKeyInReviewIsNotReadAsAccept(t *testing.T) { + // Up is ESC [ A: byte by byte, its A would accept a memory. + if got := answersTo(t, "\x1b[A\x1b[Br", 1); got[0] != "r" { + t.Fatalf("an arrow key answered %q", got[0]) + } +} + +func TestCtrlCInASingleKeyReviewQuitsWithTheTally(t *testing.T) { + if got := answersTo(t, "\x03", 1); got[0] != "q" { + t.Fatalf("Ctrl-C answered %q, want q", got[0]) + } +} + +func TestKeysThatAnswerNothingAreSwallowed(t *testing.T) { + if got := answersTo(t, "xz9 a", 1); got[0] != "a" { + t.Fatalf("got %q, want a", got[0]) + } +} + +func TestTheTerminalIsGivenBackWhenInputEndsMidReview(t *testing.T) { + var entered, restored int + answer := keyAnswers(bufio.NewReader(strings.NewReader("x")), fakeRaw(&entered, &restored)) + if _, err := answer(); err == nil { + t.Fatal("no error at the end of input") + } + if restored != entered { + t.Fatalf("raw mode entered %d, restored %d", entered, restored) + } +} + +func TestAnArrowKeyInApplicationCursorModeIsNotReadAsAccept(t *testing.T) { + // Some terminals send Up as ESC O A; its A would accept a memory. + if got := answersTo(t, "\x1bOA\x1bOBr", 1); got[0] != "r" { + t.Fatalf("an arrow key answered %q", got[0]) + } +} + +// When the terminal refused raw mode, the first answer came back as an error, +// which review reads as end of input: it quit with "0 accepted" and exit 0, +// and nobody was told the keys never worked. +func TestAReviewWhoseTerminalRefusesRawModeSaysSoAndTakesLines(t *testing.T) { + refuse := func() (func(), error) { return nil, errors.New("not a tty") } + answer := keyAnswers(bufio.NewReader(strings.NewReader("r\na\n")), refuse) + var got []string + stderr := captureStderr(t, func() { + captureStdout(t, func() { + for range 2 { + k, err := answer() + if err != nil { + t.Fatalf("answer %d: %v", len(got)+1, err) + } + got = append(got, k) + } + }) + }) + if strings.Join(got, ",") != "r,a" { + t.Fatalf("got %q, want r,a read as lines", got) + } + if !strings.Contains(stderr, "not a tty") || strings.Count(stderr, "not a tty") != 1 { + t.Fatalf("the fallback is not said exactly once:\n%s", stderr) + } +} diff --git a/cmd/logos/setup.go b/cmd/logos/setup.go index 9f9a574f..6809c98e 100644 --- a/cmd/logos/setup.go +++ b/cmd/logos/setup.go @@ -396,19 +396,34 @@ func wireHosts(vault string, opts wireOpts) (handedOff []string, err error) { // outcomes: the most important screen in the product read as a rendering // fault at exactly the moment a new user is deciding whether to trust it. showPlan := opts.dryRun || !opts.yes - if showPlan { - fmt.Print(pluginPlan) + // At a terminal the hosts to wire are a checklist, so the roster's + // pending rows are the menu's rows; printed as well, they would be the + // same hosts twice in a row, which the comment above exists to prevent. + asMenu := present > 0 && !opts.yes && !opts.dryRun && pickerAllowed(false) + roster := func(pending bool) { + if pending { + fmt.Print(pluginPlan) + } for _, r := range plan { if r.Outcome == setup.Skipped { - fmt.Printf(" %-*s — not installed\n", hostColumn, r.Host) + // A menu that could not open falls back to [Y/n] after + // the missing hosts were already listed above it. + if !(pending && asMenu) { + fmt.Printf(" %-*s — not installed\n", hostColumn, r.Host) + } continue } // The tier, because "connected" meant three different things: // Claude Code restores context on its own, Cursor and Codex do once // their hook is in, and the rest only answer when asked. - fmt.Printf(" %-*s → %s (%s)\n", hostColumn, r.Host, r.Where, r.Tier) + if pending { + fmt.Printf(" %-*s → %s (%s)\n", hostColumn, r.Host, r.Where, r.Tier) + } } } + if showPlan { + roster(!asMenu) + } if present == 0 { if pluginNote != "" { @@ -453,8 +468,21 @@ func wireHosts(vault string, opts wireOpts) (handedOff []string, err error) { fmt.Println("\n --dry-run: nothing was written.") return nil, nil } - wire := opts.yes - if !wire { + wire, asked := opts.yes, false + if asMenu { + unticked, ok, err := chooseHosts(plan, pluginLabel) + switch { + case err != nil: + fmt.Printf("\n could not show the host menu (%v) — asking instead\n", err) + roster(true) + case ok: + asked, wire = true, true + hosts, pluginSteps = leaveOut(hosts, pluginSteps, unticked) + default: + asked = true + } + } + if !wire && !asked { answer, ok := readAnswer(fmt.Sprintf("\n wire %d host(s)? [Y/n] ", present)) // Nobody there to ask is not a no. Exiting 0 let a provisioning script // (`brew install … && logos setup`) take a run that wired nothing as a @@ -466,7 +494,11 @@ func wireHosts(vault string, opts wireOpts) (handedOff []string, err error) { } if !wire { fmt.Println(" skipped; re-run `logos mcp install` when you are ready") - // The prompt cannot pick a subset, so the way to wire some of these is + if asked { + // The checklist that was just cancelled is the way to a subset. + return nil, nil + } + // [Y/n] cannot pick a subset, so the way to wire some of these is // printed with each found host's spelling, ready to trim and paste. var pick []string for _, r := range plan { @@ -905,6 +937,65 @@ func confirmNo(prompt string) bool { return ok && (answer == "y" || answer == "yes") } +// chooseHosts asks which of the hosts found to wire, all ticked to start, so +// Enter on the last row is the old Enter at [Y/n]. A [Y/n] could only take +// every host or none; leaving one out meant retyping the run with --host per +// host. It returns the hosts unticked, and ok=false for a cancel, which wires +// nothing as a no did. +func chooseHosts(plan []setup.Result, pluginLabel string) (unticked []string, ok bool, err error) { + var names, rows []string + if pluginLabel != "" { + names = append(names, "Claude Code") + rows = append(rows, fmt.Sprintf("%-*s → %s", hostColumn, "Claude Code", pluginLabel)) + } + for _, r := range plan { + if r.Outcome == setup.Pending { + names = append(names, r.Host) + rows = append(rows, fmt.Sprintf("%-*s → %s (%s)", hostColumn, r.Host, r.Where, r.Tier)) + } + } + checked := make([]bool, len(rows)) + for i := range checked { + checked[i] = true + } + fmt.Println() + _, res, _, err := pickOnTerminal(menu{ + title: "Wire which hosts?", hint: "Enter or click unticks, then Wire; q wires none", + rows: rows, checked: checked, submit: "Wire the ticked hosts", mouse: true, + }) + if err != nil || res != picked { + return nil, false, err + } + for i, c := range checked { + if !c { + unticked = append(unticked, names[i]) + } + } + return unticked, true, nil +} + +// leaveOut drops the unticked hosts from the run and says so, one line each, +// so a host that was not wired is never mistaken for one that failed. The +// Claude Code row there is the plugin: unticked, its steps do not run. +func leaveOut(hosts []setup.Host, pluginSteps [][]string, unticked []string) ([]setup.Host, [][]string) { + if len(unticked) == 0 { + return hosts, pluginSteps + } + kept := hosts[:0:0] + for _, h := range hosts { + if !slices.Contains(unticked, h.Name) { + kept = append(kept, h) + } + } + if pluginSteps != nil && slices.Contains(unticked, "Claude Code") { + pluginSteps = nil + } + for _, n := range unticked { + fmt.Printf(" %-*s — left alone: unticked\n", hostColumn, n) + } + return kept, pluginSteps +} + // readAnswer prints prompt and reads one answer, lowercased. ok is false when there // is no answer to read. func readAnswer(prompt string) (answer string, ok bool) { diff --git a/cmd/logos/setup_hosts_menu_test.go b/cmd/logos/setup_hosts_menu_test.go new file mode 100644 index 00000000..0674df5a --- /dev/null +++ b/cmd/logos/setup_hosts_menu_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/Coder8124/logos/internal/setup" +) + +// recordingHosts are detected hosts whose registration is written down +// instead of done. +func recordingHosts(t *testing.T, names ...string) *[]string { + t.Helper() + var wired []string + var hosts []setup.Host + for _, name := range names { + hosts = append(hosts, setup.Host{ + Name: name, + Detect: func() bool { return true }, + Where: func() string { return "/nowhere/config.json" }, + Register: func(setup.Server) (setup.Outcome, error) { + wired = append(wired, name) + return setup.Registered, nil + }, + }) + } + fakeHosts(t) + old := detectHosts + detectHosts = func() []setup.Host { return hosts } + t.Cleanup(func() { detectHosts = old }) + return &wired +} + +// untick answers the host menu: it unticks the given rows and submits. +func untick(t *testing.T, res pick, rows ...int) *[]menu { + t.Helper() + var shown []menu + was := pickOnTerminal + pickOnTerminal = func(m menu) (int, pick, int, error) { + shown = append(shown, m) + for _, r := range rows { + m.checked[r] = false + } + return len(m.rows), res, len(m.rows) + 2, nil + } + t.Cleanup(func() { pickOnTerminal = was }) + return &shown +} + +func TestSetupAtATerminalWiresOnlyTheHostsLeftTicked(t *testing.T) { + dir := setupInFakeHome(t) + wired := recordingHosts(t, "Fakey Cursor", "Fakey Codex", "Fakey Zed") + atTerminal(t, true) + shown := untick(t, picked, 1) + + var err error + out := captureStdout(t, func() { _, err = wireHosts(dir, wireOpts{}) }) + if err != nil { + t.Fatalf("wireHosts: %v\n%s", err, out) + } + if len(*shown) != 1 { + t.Fatalf("the host menu opened %d times", len(*shown)) + } + for i, c := range (*shown)[0].checked { + if !c && i != 1 { + t.Fatalf("row %d opened unticked; every host found starts ticked", i) + } + } + if !reflect.DeepEqual(*wired, []string{"Fakey Cursor", "Fakey Zed"}) { + t.Fatalf("wired %v, want the two left ticked", *wired) + } + if !strings.Contains(out, "Fakey Codex") || !strings.Contains(out, "left alone: unticked") { + t.Fatalf("the unticked host was not named:\n%s", out) + } + if strings.Contains(out, "[Y/n]") { + t.Fatalf("the menu asked [Y/n] as well:\n%s", out) + } +} + +func TestCancellingTheSetupHostMenuWiresNothing(t *testing.T) { + dir := setupInFakeHome(t) + wired := recordingHosts(t, "Fakey Cursor", "Fakey Codex") + atTerminal(t, true) + untick(t, cancelled) + + var err error + out := captureStdout(t, func() { _, err = wireHosts(dir, wireOpts{}) }) + if err != nil { + t.Fatalf("wireHosts: %v\n%s", err, out) + } + if len(*wired) != 0 { + t.Fatalf("a cancel wired %v", *wired) + } + if !strings.Contains(out, "skipped") { + t.Fatalf("the cancel was not said:\n%s", out) + } + // The --host line was the [Y/n] prompt's only way to a subset; after a + // checklist it sends the person to type what the menu just offered. + if strings.Contains(out, "--host") { + t.Fatalf("a cancelled checklist still teaches --host:\n%s", out) + } +} + +// When the menu could not be drawn, setup fell back to [Y/n] and printed the +// whole roster again, so every host that is not installed was listed twice. +func TestAHostMenuThatCannotOpenListsEachMissingHostOnce(t *testing.T) { + dir := setupInFakeHome(t) + recordingHosts(t, "Fakey Cursor") + gone := setup.Host{Name: "Fakey Absent", Detect: func() bool { return false }, Where: func() string { return "" }} + withGone := detectHosts + detectHosts = func() []setup.Host { return append(withGone(), gone) } + atTerminal(t, true) + was := pickOnTerminal + pickOnTerminal = func(menu) (int, pick, int, error) { return 0, cancelled, 0, errors.New("no raw mode") } + t.Cleanup(func() { pickOnTerminal = was }) + + out := captureStdout(t, func() { wireHosts(dir, wireOpts{}) }) + if !strings.Contains(out, "asking instead") { + t.Fatalf("the fallback was not taken:\n%s", out) + } + if n := strings.Count(out, "Fakey Absent"); n != 1 { + t.Fatalf("the missing host is listed %d times:\n%s", n, out) + } + if !strings.Contains(out, "Fakey Cursor") { + t.Fatalf("the fallback prompt does not say what it would wire:\n%s", out) + } +} + +func TestSetupWithYesNeverOpensTheHostMenu(t *testing.T) { + dir := setupInFakeHome(t) + wired := recordingHosts(t, "Fakey Cursor", "Fakey Codex") + atTerminal(t, true) + shown := untick(t, picked, 0) + + captureStdout(t, func() { wireHosts(dir, wireOpts{yes: true}) }) + if len(*shown) != 0 || len(*wired) != 2 { + t.Fatalf("--yes opened %d menus and wired %v", len(*shown), *wired) + } +} diff --git a/cmd/logos/usage.go b/cmd/logos/usage.go index df89291e..28b9e963 100644 --- a/cmd/logos/usage.go +++ b/cmd/logos/usage.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "golang.org/x/term" + "github.com/Coder8124/logos/internal/activity" "github.com/Coder8124/logos/internal/contextpack" "github.com/Coder8124/logos/internal/index" @@ -75,6 +77,9 @@ func runUsage(args []string) error { if _, err := os.Stat(v); err != nil { return missingVaultError(v) } + if term.IsTerminal(int(os.Stdout.Fd())) { + return usageDashboard(v, project, hasFlag(args, "--week"), usd) + } events, bad, err := usagepkg.Read(v) if err != nil { return err diff --git a/cmd/logos/usage_dash.go b/cmd/logos/usage_dash.go new file mode 100644 index 00000000..ca056ca4 --- /dev/null +++ b/cmd/logos/usage_dash.go @@ -0,0 +1,540 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/term" + + "github.com/Coder8124/logos/internal/deadend" + "github.com/Coder8124/logos/internal/index" + "github.com/Coder8124/logos/internal/insight" + "github.com/Coder8124/logos/internal/memory" + "github.com/Coder8124/logos/internal/scope" + "github.com/Coder8124/logos/internal/spend" + usagepkg "github.com/Coder8124/logos/internal/usage" +) + +// dash is everything the usage dashboard draws, gathered before anything is +// drawn so the layout is a pure function of it and can be tested at any width. +type dash struct { + who, window string + days []string // "2006-01-02", oldest first + now time.Time + + saved usagepkg.Totals + savedDaily []float64 + rate float64 + recording bool + bad int + + spend *spend.Report + spendErr error + + used, dormant []memory.Memory + dormantN int + memNote string + + recall *recallFact + recallNote string +} + +// recallFact is the oldest dead end handed back in the window: how long it sat +// in the vault before it was needed, which is the thing a memory exists for. +type recallFact struct { + text, agent string + ruled, shown int64 +} + +// usageDashboard draws `logos stats --usage` at a terminal. Piped, the command +// prints the text receipts instead; those carry each number's method in full, +// and a script reading them must not get box-drawing characters. +func usageDashboard(v, project string, week bool, rate float64) error { + n := 30 + if week { + n = 7 + } + d, err := gatherDash(v, project, n, rate, time.Now()) + if err != nil { + return err + } + w := 100 + if cols, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && cols > 0 { + w = cols + } + fmt.Print(renderDash(d, w)) + return nil +} + +// gatherDash fails only when the usage ledger cannot be read, as the piped +// receipts do: drawn anyway, every Logos panel would read "nothing recorded" +// for a ledger that may hold months. A host's transcripts or the index +// failing is shown in its own panel, since the rest of the page still holds. +func gatherDash(v, project string, n int, rate float64, now time.Time) (dash, error) { + d := dash{who: "every project", window: fmt.Sprintf("the last %d days", n), now: now, rate: rate, recording: usagepkg.Recording(v)} + if project != "" { + d.who = project + } + start := time.Date(now.Year(), now.Month(), now.Day()-(n-1), 0, 0, 0, 0, now.Location()) + for i := 0; i < n; i++ { + d.days = append(d.days, start.AddDate(0, 0, i).Format("2006-01-02")) + } + + events, bad, err := usagepkg.Read(v) + if err != nil { + return d, fmt.Errorf("reading the usage ledger: %w", err) + } + d.bad = bad + events = usagepkg.Within(events, start.Unix()) + d.saved = usagepkg.Sum(events, project) + daily := map[string]float64{} + for _, e := range events { + if e.Kind == usagepkg.KindPack && (project == "" || usagepkg.SameProject(e.Project, project)) { + daily[time.Unix(e.TS, 0).Format("2006-01-02")] += float64(e.Full - e.Sent) + } + } + for _, day := range d.days { + d.savedDaily = append(d.savedDaily, daily[day]) + } + + d.spend, d.spendErr = spend.Read(start, project) + + if _, err := os.Stat(filepath.Join(v, ".logos", "index.db")); err != nil { + d.memNote = "no index yet — `logos index` builds it" + d.recallNote = d.memNote + return d, nil + } + ix, err := index.Open(v) + if err != nil { + d.memNote = "opening the index: " + err.Error() + d.recallNote = d.memNote + return d, nil + } + defer ix.Close() + gatherMemories(&d, ix, project) + gatherRecall(&d, v, ix, project, events) + return d, nil +} + +func gatherMemories(d *dash, ix *index.Index, project string) { + var mems []memory.Memory + var err error + if project == "" { + mems, err = memory.All(ix.DB) + } else { + mems, err = memory.AllInProject(ix.DB, scope.NormalizeArg(project)) + } + if err != nil { + d.memNote = "reading memories: " + err.Error() + return + } + for _, m := range mems { + if m.Uses > 0 { + d.used = append(d.used, m) + } + if insight.Dormant(m, d.now) { + d.dormant = append(d.dormant, m) + } + } + sort.SliceStable(d.used, func(i, j int) bool { return d.used[i].Uses > d.used[j].Uses }) + sort.SliceStable(d.dormant, func(i, j int) bool { return insight.LastTouched(d.dormant[i]) < insight.LastTouched(d.dormant[j]) }) + d.dormantN = len(d.dormant) + if len(mems) == 0 { + d.memNote = "no memories yet" + } +} + +// gatherRecall finds, among the dead ends handed back in the window, the one +// recorded longest before it was handed back. The ledger names rulings by a +// fingerprint only, so they are matched against the vault's checkpoints; one +// since retired or deleted is simply not found. +func gatherRecall(d *dash, v string, ix *index.Index, project string, events []usagepkg.Event) { + shown := map[string]int64{} // ref -> first time shown in the window + for _, e := range events { + if e.Kind != usagepkg.KindDeadEnd && e.Kind != usagepkg.KindAtEdit { + continue + } + if project != "" && !usagepkg.SameProject(e.Project, project) { + continue + } + for _, r := range e.Refs { + if _, ok := shown[r]; !ok { + shown[r] = e.TS + } + } + } + if len(shown) == 0 { + d.recallNote = "no dead end was handed back in " + d.window + return + } + rulings, err := deadend.Collect(v, ix.DB, "") + if err != nil { + d.recallNote = "reading checkpoints: " + err.Error() + return + } + for _, r := range rulings { + at, ok := shown[usagepkg.Ref(r.Record.Raw)] + if !ok || r.When == 0 || at < r.When { + continue + } + if d.recall == nil || at-r.When > d.recall.shown-d.recall.ruled { + d.recall = &recallFact{text: r.Text, agent: r.Agent, ruled: r.When, shown: at} + } + } + if d.recall == nil { + d.recallNote = fmt.Sprintf("%d dead end%s handed back; none still in the vault's checkpoints", len(shown), pluralS(len(shown))) + } +} + +// renderDash lays the panels out two to a row when the terminal is wide +// enough to read them side by side, one to a row when it is not. +func renderDash(d dash, width int) string { + width = min(max(width, 40), 140) + var b strings.Builder + fmt.Fprintf(&b, "logos stats --usage — %s, %s\n", d.who, d.window) + if !d.recording { + b.WriteString("· recording is off — Logos's own totals stopped when it was turned off. `logos stats --usage on` resumes it.\n") + } + if d.bad > 0 { + fmt.Fprintf(&b, "· %d unreadable ledger line%s skipped — the totals are missing them\n", d.bad, pluralS(d.bad)) + } + b.WriteString("\n") + + type panel struct { + title string + body func(w int) []string + } + panels := []panel{ + {"Tokens saved", d.savedPanel}, + {"Agent spend", d.spendPanel}, + {"Memories", d.memoryPanel}, + {"Longest recall", d.recallPanel}, + {"Session size", d.sessionPanel}, + } + if width < 100 { + for _, p := range panels { + b.WriteString(strings.Join(box(p.title, p.body(width-4), width), "\n")) + b.WriteString("\n") + } + } else { + colW := (width - 1) / 2 + for i := 0; i < len(panels); i += 2 { + left := box(panels[i].title, panels[i].body(colW-4), colW) + var right []string + if i+1 < len(panels) { + right = box(panels[i+1].title, panels[i+1].body(colW-4), colW) + } + for j := 0; j < max(len(left), len(right)); j++ { + l, r := blankBox(left, j, colW), blankBox(right, j, 0) + b.WriteString(strings.TrimRight(l+" "+r, " ") + "\n") + } + } + } + // The receipts hold what has no panel here — accepted memories, avoided + // retries, procedures — and each number's method, so say how to reach them. + b.WriteString("\nThe full receipts, with each number's method: `logos stats --usage | cat`\n") + return b.String() +} + +func blankBox(lines []string, j, w int) string { + if j < len(lines) { + return lines[j] + } + return strings.Repeat(" ", w) +} + +// box frames body in a rounded border w columns wide. A line too long for it +// is wrapped, not cut: each box is drawn whole before two are set side by +// side, so a taller one only leaves blank rows beside its neighbour. +func box(title string, body []string, w int) []string { + in := w - 4 + top := "╭─ " + title + " " + strings.Repeat("─", max(w-5-utf8.RuneCountInString(title), 0)) + "╮" + out := []string{top} + for _, l := range body { + lines := []string{l} + if utf8.RuneCountInString(l) > in { + lines = wrap(l, in) + } + for _, wl := range lines { + out = append(out, "│ "+fit(wl, in)+" │") + } + } + return append(out, "╰"+strings.Repeat("─", w-2)+"╯") +} + +func fit(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:max(n-1, 0)]) + "…" + } + return s + strings.Repeat(" ", n-len(r)) +} + +// spark draws vs as block heights, scaled to the largest. An empty day is +// a dot, not the lowest bar, so a quiet day never reads as a small one. +func spark(vs []float64, n int) string { + if len(vs) > n { + vs = vs[len(vs)-n:] + } + top := 0.0 + for _, v := range vs { + top = max(top, v) + } + bars := []rune("▁▂▃▄▅▆▇█") + var b strings.Builder + for _, v := range vs { + if v <= 0 || top == 0 { + b.WriteRune('·') + continue + } + b.WriteRune(bars[min(int(v/top*float64(len(bars)-1)+0.5), len(bars)-1)]) + } + return b.String() +} + +// human writes a token count the way the eye reads it: 1.2M, 412K, 830. +func human(n int64) string { + switch { + case n >= 1_000_000_000: + return fmt.Sprintf("%.2fB", float64(n)/1e9) + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1e6) + case n >= 10_000: + return fmt.Sprintf("%dK", n/1000) + case n >= 1000: + return fmt.Sprintf("%.1fK", float64(n)/1e3) + } + return fmt.Sprint(n) +} + +func dollars(f float64) string { + if f >= 1000 { + return "$" + thousands(int(f+0.5)) + } + return fmt.Sprintf("$%.2f", f) +} + +func ago(from, to int64) string { + days := (to - from) / 86400 + switch { + case days >= 60: + return fmt.Sprintf("%d months", days/30) + case days >= 1: + return fmt.Sprintf("%d day%s", days, pluralS(int(days))) + } + h := (to - from) / 3600 + return fmt.Sprintf("%d hour%s", h, pluralS(int(h))) +} + +func (d dash) savedPanel(w int) []string { + t := d.saved + if t.Packs == 0 && t.DeadEndChecks == 0 && t.AtEdit == 0 && t.Restores == 0 && t.Handoffs == 0 && t.Avoided == 0 && t.Procedures == 0 { + return []string{"nothing recorded in " + d.window, "", "fills as context packs are sent (resume, context)", "and dead ends are handed back (before_you_try, tried, why)"} + } + out := []string{ + fmt.Sprintf("~%s tokens left out of %d pack%s", human(int64(t.Saved())), t.Packs, pluralS(t.Packs)), + fmt.Sprintf("sent ~%s of ~%s drawn from", human(int64(t.Sent)), human(int64(t.Full))), + } + if d.rate > 0 { + out = append(out, fmt.Sprintf("≈ %s at $%g per million, the rate you gave", dollars(float64(t.Saved())*d.rate/1e6), d.rate)) + } + out = append(out, "") + out = append(out, daily(d.savedDaily, d.days, w)...) + out = append(out, "", + fmt.Sprintf("%d dead end%s handed back before a retry; %d restore%s, %d handoff%s", + t.Rulings, pluralS(t.Rulings), t.Restores, pluralS(t.Restores), t.Handoffs, pluralS(t.Handoffs)), + "each pack against its own candidates — not your API bill") + return out +} + +// daily draws one bar per day with the first and last day named under it. +// A week in a wide box gets up to three columns a day, so its bars read as a +// chart and its two dates fit under them. +func daily(vs []float64, days []string, w int) []string { + if len(vs) > w { + vs, days = vs[len(vs)-w:], days[len(days)-w:] + } + if len(vs) == 0 { + return nil + } + per := min(max(w/len(vs), 1), 3) + var bars strings.Builder + for _, r := range spark(vs, len(vs)) { + bars.WriteString(strings.Repeat(string(r), per)) + } + label := func(s string) string { + t, _ := time.Parse("2006-01-02", s) + return t.Format("2 Jan") + } + a, z := label(days[0]), label(days[len(days)-1]) + axis := a + if gap := len(vs)*per - utf8.RuneCountInString(a) - utf8.RuneCountInString(z); gap >= 1 { + axis = a + strings.Repeat(" ", gap) + z + } + return []string{bars.String(), axis} +} + +func (d dash) spendPanel(w int) []string { + if d.spendErr != nil { + return []string{"could not read the hosts' transcripts:", d.spendErr.Error()} + } + r := d.spend + if r == nil || len(r.Hosts) == 0 { + return []string{"no Claude Code or Codex transcripts found on this machine"} + } + if r.Total.Total() == 0 { + return []string{fmt.Sprintf("no agent requests in %s (%s)", d.window, strings.Join(r.Hosts, ", "))} + } + out := []string{fmt.Sprintf("%s across %s tokens", dollars(r.Total.USD), human(r.Total.Total()))} + if r.Total.Unpriced > 0 { + out = append(out, fmt.Sprintf("+ %s tokens from models with no list price", human(r.Total.Unpriced))) + } + var perDay []float64 + for _, day := range d.days { + if b := r.Days[day]; b != nil { + perDay = append(perDay, b.USD+float64(b.Unpriced)*1e-12) // an unpriced day still shows a bar + } else { + perDay = append(perDay, 0) + } + } + out = append(out, "") + out = append(out, daily(perDay, d.days, w)...) + out = append(out, "", "Top models") + out = append(out, spendRows(r.Models, w)...) + out = append(out, "", "Top projects") + out = append(out, spendRows(r.Projects, w)...) + out = append(out, "", "Tokens the hosts recorded ("+strings.Join(r.Hosts, ", ")+"), priced at") + out = append(out, wrap(spend.PriceSource, w)...) + if r.Unreadable > 0 { + out = append(out, fmt.Sprintf("%d unreadable transcript line%s skipped", r.Unreadable, pluralS(r.Unreadable))) + } + if r.Skipped > 0 { + out = append(out, fmt.Sprintf("%d transcript%s could not be read to the end; the totals are missing the rest", r.Skipped, pluralS(r.Skipped))) + } + return out +} + +func spendRows(m map[string]*spend.Bucket, w int) []string { + var out []string + for _, k := range spend.Top(m, 5) { + b := m[k] + // A "+" marks a row whose dollars leave some of its tokens out — a + // project that ran on an unpriced model too — so it is not read as + // the whole cost. + price := dollars(b.USD) + switch { + case b.Unpriced == b.Total(): + price = "no list price" + case b.Unpriced > 0: + price += "+" + } + right := fmt.Sprintf("%13s %8s", price, human(b.Total())) + n := max(w-2-utf8.RuneCountInString(right)-1, 4) + // Model and project names come from the hosts' transcripts, which a + // repository's folder name can reach. + out = append(out, " "+fit(terminalSafe(k, n), n)+" "+right) + } + return out +} + +func (d dash) memoryPanel(w int) []string { + if d.memNote != "" { + return []string{d.memNote} + } + out := []string{"Most used"} + if len(d.used) == 0 { + out = append(out, " none drawn on yet") + } + for _, m := range d.used[:min(len(d.used), 5)] { + n := fmt.Sprintf("×%d", m.Uses) + out = append(out, fmt.Sprintf(" %-5s %s", n, terminalSafe(m.Text, w-9))) + } + out = append(out, "", fmt.Sprintf("Taking space — %d unused for %d+ days", d.dormantN, int(insight.DormantAfter.Hours()/24))) + for _, m := range d.dormant[:min(len(d.dormant), 5)] { + age := fmt.Sprintf("%dd", (d.now.Unix()-insight.LastTouched(m))/86400) + out = append(out, fmt.Sprintf(" %-5s %s", age, terminalSafe(m.Text, w-9))) + } + if d.dormantN > 0 { + out = append(out, "", "`logos stats` lists them; `logos forget` or pin_memory decides") + } + return out +} + +func (d dash) recallPanel(w int) []string { + if d.recall == nil { + return []string{d.recallNote} + } + r := d.recall + who := "" + if r.agent != "" { + who = " by " + terminalSafe(r.agent, 40) + } + out := []string{ + ago(r.ruled, r.shown) + " between ruling it out and needing it", + "", + } + // Four lines of the route: enough to recognise it, and `logos stats + // --usage` piped or `why` has the rest. + text := wrap(terminalSafe(r.text, 400), w) + out = append(out, text[:min(len(text), 4)]...) + out = append(out, "", + fmt.Sprintf("ruled out%s on %s", who, time.Unix(r.ruled, 0).Format("2 Jan 2006")), + "handed back on "+time.Unix(r.shown, 0).Format("2 Jan 2006")) + return out +} + +func (d dash) sessionPanel(w int) []string { + if d.spend == nil || len(d.spend.Sessions) == 0 { + return []string{"no agent sessions in " + d.window} + } + by := map[string][]float64{} + for _, s := range d.spend.Sessions { + by[s.Model] = append(by[s.Model], float64(s.Peak)) + } + models := make([]string, 0, len(by)) + for m := range by { + models = append(models, m) + } + sort.Slice(models, func(i, j int) bool { + if len(by[models[i]]) != len(by[models[j]]) { + return len(by[models[i]]) > len(by[models[j]]) + } + return models[i] < models[j] + }) + out := []string{"largest context per session, oldest to newest;", "then the median and how many sessions", ""} + for _, m := range models[:min(len(models), 5)] { + peaks := by[m] + sorted := append([]float64(nil), peaks...) + sort.Float64s(sorted) + right := fmt.Sprintf("%6s · %d", human(int64(sorted[len(sorted)/2])), len(peaks)) + name := 18 + sw := max(w-name-utf8.RuneCountInString(right)-4, 4) + out = append(out, fmt.Sprintf(" %s %s %s", fit(terminalSafe(m, name), name), fit(spark(peaks, sw), sw), right)) + } + return out +} + +// wrap breaks s into lines of at most w runes at spaces. +func wrap(s string, w int) []string { + var out []string + line := "" + for _, word := range strings.Fields(s) { + if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) > w { + out = append(out, line) + line = "" + } + if line != "" { + line += " " + } + line += word + } + if line != "" { + out = append(out, line) + } + return out +} diff --git a/cmd/logos/usage_dash_test.go b/cmd/logos/usage_dash_test.go new file mode 100644 index 00000000..b432df16 --- /dev/null +++ b/cmd/logos/usage_dash_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/Coder8124/logos/internal/memory" + "github.com/Coder8124/logos/internal/spend" + "github.com/Coder8124/logos/internal/transcript" + usagepkg "github.com/Coder8124/logos/internal/usage" +) + +func sampleDash() dash { + now := time.Date(2026, 11, 6, 12, 0, 0, 0, time.Local) + d := dash{who: "every project", window: "the last 7 days", now: now, recording: true, rate: 3} + for i := 6; i >= 0; i-- { + d.days = append(d.days, now.AddDate(0, 0, -i).Format("2006-01-02")) + } + d.saved = usagepkg.Totals{Packs: 12, Sent: 40_000, Full: 1_240_000, DeadEndChecks: 3, Rulings: 5} + d.savedDaily = []float64{0, 100, 0, 5000, 200_000, 0, 9000} + d.spend = &spend.Report{ + Total: spend.Bucket{Usage: spend.Usage{Input: 5_000_000, Output: 200_000}, USD: 534.25, Unpriced: 27_500_000}, + Models: map[string]*spend.Bucket{"claude-opus-5-5": {USD: 457.39, Usage: spend.Usage{Input: 4e6}}, "a-model-with-an-extraordinarily-long-name": {Unpriced: 9, Usage: spend.Usage{Input: 9}}}, + Projects: map[string]*spend.Bucket{"brain": {USD: 441.15, Usage: spend.Usage{Input: 3e6}}}, + Days: map[string]*spend.Bucket{d.days[6]: {USD: 80}}, + Sessions: []spend.Session{{Model: "claude-opus-5-5", Peak: 90_000}, {Model: "claude-opus-5-5", Peak: 400_000}, {Model: "gpt-6-luna", Peak: 30_000}}, + Hosts: []string{"Claude Code", "Codex"}, + } + d.used = []memory.Memory{{Text: "the vault is truth\x1b[2J; the index is a cache", Uses: 14}} + d.dormant = []memory.Memory{{Text: "an old note", Created: now.AddDate(0, 0, -90).Unix()}} + d.dormantN = 1 + d.recall = &recallFact{text: "route: poll the plans dir | observation: passed on the unfixed code", agent: "claude-code", ruled: now.AddDate(0, 0, -40).Unix(), shown: now.Unix()} + return d +} + +func TestTheUsageDashboardFitsTheTerminalAtAnyWidth(t *testing.T) { + for _, w := range []int{40, 80, 99, 100, 120, 140, 300} { + out := renderDash(sampleDash(), w) + limit := min(max(w, 40), 140) + for i, line := range strings.Split(out, "\n") { + // Only the panels are laid out to the width; the header and footer + // are sentences the terminal wraps. + boxed := strings.ContainsAny(line[:min(len(line), 3)], "│╭╰") + if n := utf8.RuneCountInString(line); boxed && n > limit { + t.Fatalf("width %d: line %d is %d columns: %q", w, i, n, line) + } + } + } +} + +func TestTheUsageDashboardDrawsEveryPanel(t *testing.T) { + for _, w := range []int{80, 120} { + out := renderDash(sampleDash(), w) + for _, want := range []string{ + "Tokens saved", "~1.2M tokens left out of 12 packs", "≈ $3.60 at $3 per million", + "Agent spend", "$534.25", "27.5M tokens from models with no list price", "no list price", "claude-opus-5-5", "brain", spend.PriceSource[:20], + "Memories", "×14", "Taking space — 1 unused for 60+ days", "90d", + "Longest recall", "40 days between ruling it out and needing it", "by claude-code", + "Session size", "gpt-6-luna", + } { + if !strings.Contains(out, want) { + t.Errorf("width %d: missing %q in\n%s", w, want, out) + } + } + } +} + +func TestVaultTextOnTheDashboardCannotDriveTheTerminal(t *testing.T) { + if out := renderDash(sampleDash(), 120); strings.Contains(out, "\x1b") { + t.Fatal("a memory's escape sequence reached the dashboard") + } +} + +func TestAWideTerminalSetsThePanelsSideBySide(t *testing.T) { + wide, narrow := renderDash(sampleDash(), 120), renderDash(sampleDash(), 80) + if !strings.Contains(wide, "╮ ╭") { + t.Fatal("at 120 columns the panels are not in two columns") + } + if strings.Contains(narrow, "╮ ╭") { + t.Fatal("at 80 columns the panels are side by side") + } +} + +func TestAQuietDayIsADotNotTheLowestBar(t *testing.T) { + if got := []rune(spark([]float64{0, 1, 8}, 10)); len(got) != 3 || got[0] != '·' || got[1] == '·' || got[2] != '█' { + t.Fatalf("spark = %q, want a dot, a low bar, a full bar", string(got)) + } +} + +func TestAnEmptyDashboardSaysWhatFillsIt(t *testing.T) { + d := dash{who: "every project", window: "the last 30 days", recording: false, memNote: "no index yet — `logos index` builds it", recallNote: "no index yet — `logos index` builds it"} + out := renderDash(d, 100) + for _, want := range []string{"recording is off", "nothing recorded in the last 30 days", "no index yet", "no agent sessions"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } +} + +func TestHostNamesOnTheDashboardCannotDriveTheTerminal(t *testing.T) { + d := sampleDash() + d.spend.Models["evil\x1b[2Jmodel"] = &spend.Bucket{USD: 999, Usage: spend.Usage{Input: 1}} + d.spend.Projects["evil\x1b]0;pwn\x07proj"] = &spend.Bucket{USD: 999, Usage: spend.Usage{Input: 1}} + d.spend.Sessions = append(d.spend.Sessions, spend.Session{Model: "evil\x1b[2Jmodel", Peak: 1}, spend.Session{Model: "evil\x1b[2Jmodel", Peak: 1}, spend.Session{Model: "evil\x1b[2Jmodel", Peak: 1}) + d.recall.agent = "agent\x1b[31m" + for _, w := range []int{80, 120} { + if out := renderDash(d, w); strings.Contains(out, "\x1b") { + t.Fatalf("at %d columns a host-recorded name put an escape on the terminal", w) + } + } +} + +func TestAnUnreadableLedgerIsAnErrorNotAnEmptyDashboard(t *testing.T) { + v := t.TempDir() + t.Setenv(transcript.LogosClaudeProjectsEnv, t.TempDir()) + t.Setenv(transcript.LogosCodexSessionsEnv, t.TempDir()) + if err := os.MkdirAll(filepath.Join(v, usagepkg.Dir, time.Now().Format("2006-01")+".jsonl"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := gatherDash(v, "", 30, 0, time.Now()); err == nil { + t.Fatal("a ledger that could not be read was drawn as an empty dashboard") + } +} + +func TestARowPricedOnlyInPartSaysSo(t *testing.T) { + rows := spendRows(map[string]*spend.Bucket{"brain": {USD: 12, Unpriced: 5, Usage: spend.Usage{Input: 10}}}, 60) + if len(rows) != 1 || !strings.Contains(rows[0], "$12.00+") { + t.Fatalf("rows = %q, want the partly priced row marked $12.00+", rows) + } +} + +func TestADashboardWithOnlyRestoresIsNotEmpty(t *testing.T) { + d := sampleDash() + d.saved = usagepkg.Totals{Restores: 2} + if lines := strings.Join(d.savedPanel(60), "\n"); strings.Contains(lines, "nothing recorded") { + t.Fatal("two restores were drawn as nothing recorded") + } +} diff --git a/internal/health/health.go b/internal/health/health.go index 86d0d5bc..17a815ba 100644 --- a/internal/health/health.go +++ b/internal/health/health.go @@ -151,6 +151,9 @@ func Run(in Input) Report { if c, ok := checkOldHostPin(in.Hosts); ok { r.Add(c) } + if c, ok := checkSessionHooks(in.Hosts); ok { + r.Add(c) + } if c, ok := checkPlugin(in.Version); ok { r.Add(c) } diff --git a/internal/health/hosts.go b/internal/health/hosts.go index c2b24aad..e631d040 100644 --- a/internal/health/hosts.go +++ b/internal/health/hosts.go @@ -267,3 +267,26 @@ func CheckPlugin(version string) (c Check) { c.Detail = "plugin " + pluginVersion return c } + +// checkSessionHooks reads each host's hooks file the way the host reads it. +// Registration can be perfect while the hook is dead: a Codex file an earlier +// setup wrote with Cursor's "version" key makes Codex run none of its hooks, +// and the hosts check still said "detected", because detected is all it asks. +// ok is false when every hook would run, so a healthy machine sees no row. +func checkSessionHooks(hosts []setup.Host) (Check, bool) { + problems := setup.HookProblems(hosts) + if len(problems) == 0 { + return Check{}, false + } + var details, fixes []string + for _, p := range problems { + details = append(details, fmt.Sprintf("%s (%s): %s", p.Host, p.Path, p.Detail)) + fixes = append(fixes, fmt.Sprintf("`logos setup --host %s`", strings.ToLower(p.Host))) + } + return Check{ + Name: "session hooks", + State: Failed, + Detail: strings.Join(details, "; "), + Fix: "run " + strings.Join(fixes, " and ") + " — it rewrites the hook and keeps a copy of the file it found", + }, true +} diff --git a/internal/insight/insight.go b/internal/insight/insight.go index 4472f09e..1e9b6939 100644 --- a/internal/insight/insight.go +++ b/internal/insight/insight.go @@ -74,11 +74,11 @@ const Degraded = "mechanical tier only — no model-augmented insight generator // repeating. Insight is about a standing problem, not archaeology. const recurringBlockerWindow = 8 -// dormantAfter is how long a memory can go unused before it is worth a +// DormantAfter is how long a memory can go unused before it is worth a // second look. Long enough that an ordinary lull between sessions on a // project doesn't trip it, short enough that it still catches something a // user genuinely forgot they knew. -const dormantAfter = 60 * 24 * time.Hour +const DormantAfter = 60 * 24 * time.Hour // blockerAkin treats two blockers as the same standing problem when they // share more than half their meaningful words — restated in different words @@ -218,10 +218,26 @@ func recurringBlockers(vaultDir, project string) (found []Insight, checkpoints, return out, checkpoints, len(projects), nil } -// dormantMemories finds a memory nobody has drawn on in dormantAfter, so it +// dormantMemories finds a memory nobody has drawn on in DormantAfter, so it // can be re-confirmed, re-pinned, or forgotten rather than silently going // stale. A memory already excluded from recall (PinNever) is not this // generator's business — its owner already decided to stop hearing about it. +// LastTouched is when a memory was last drawn on, or written if never. +func LastTouched(m memory.Memory) int64 { + if m.LastUsed != 0 { + return m.LastUsed + } + return m.Created +} + +// Dormant is the one rule for a memory taking space: not drawn on for +// DormantAfter and not pinned out of use. `logos stats` and the usage +// dashboard both ask it, so they never disagree about the same vault. +func Dormant(m memory.Memory, now time.Time) bool { + last := LastTouched(m) + return m.Pin != memory.PinNever && last != 0 && last <= now.Add(-DormantAfter).Unix() +} + func dormantMemories(db *sql.DB, project string, now time.Time) ([]Insight, int, error) { var mems []memory.Memory var err error @@ -234,19 +250,12 @@ func dormantMemories(db *sql.DB, project string, now time.Time) ([]Insight, int, return nil, 0, err } - cutoff := now.Add(-dormantAfter).Unix() var out []Insight for _, m := range mems { - if m.Pin == memory.PinNever { - continue - } - last := m.LastUsed - if last == 0 { - last = m.Created - } - if last == 0 || last > cutoff { + if !Dormant(m, now) { continue } + last := LastTouched(m) days := int(now.Unix()-last) / 86400 out = append(out, Insight{ Kind: "dormant-memory", diff --git a/internal/setup/hookcheck.go b/internal/setup/hookcheck.go new file mode 100644 index 00000000..65e02fe6 --- /dev/null +++ b/internal/setup/hookcheck.go @@ -0,0 +1,108 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// HookProblem is one host whose session-start hook will not do its job, read +// off the file the way that host reads it. +type HookProblem struct { + Host string + Path string + Detail string +} + +// HookProblems reads the hooks files logos writes and names every way one of +// them fails at session start. Setup fixes each of these, but nobody re-runs +// setup after an upgrade: a Codex hooks file an earlier logos broke stayed +// broken through every release that knew how to fix it, and doctor said the +// hosts were fine, because it only asked whether they were installed. +func HookProblems(hosts []Host) []HookProblem { + var out []HookProblem + for _, h := range hosts { + if h.Hooks == nil || h.Detect == nil || !h.Detect() { + continue + } + host := strings.ToLower(h.Name) + path := hooksPathFor(h.Name) + if path == "" { + continue + } + if d := hookFileProblem(path, host); d != "" { + out = append(out, HookProblem{Host: h.Name, Path: path, Detail: d}) + } + } + return out +} + +// hookFileProblem is "" for a hooks file the host can read whose logos hook, +// if it has one, will run. A file with no logos hook is not a problem here: +// the user may have chosen MCP alone, and setup reports that case itself. +func hookFileProblem(path, host string) string { + if _, err := os.Stat(path); os.IsNotExist(err) { + return "" + } + f, err := loadHookFile(path) + if err != nil { + return err.Error() + } + if host == "codex" { + if _, has := f.rest["version"]; has { + return "Codex refuses this file (\"unknown field `version`\") and runs none of the hooks in it" + } + } + var cmds []string + flat, _ := flatEntries(path, &f) + for _, e := range flat { + if isLogosHook(e.command(), host) { + if host == "codex" { + return "logos's hook is in Cursor's shape, which Codex never runs" + } + cmds = append(cmds, e.command()) + } + } + if host == "codex" { + groups, _ := codexGroups(path, &f) + for _, g := range groups { + handlers, _ := groupHandlers(path, g) + for _, h := range handlers { + if isLogosHook(h.command(), host) { + cmds = append(cmds, h.command()) + } + } + } + } + for _, cmd := range cmds { + if strings.Contains(cmd, " mcp serve hook ") { + return "logos's hook starts an MCP server instead of the hook, so no session opens with a handoff" + } + if bin := hookBinary(cmd); bin != "" { + if _, err := os.Stat(bin); os.IsNotExist(err) { + return fmt.Sprintf("logos's hook runs %s, which no longer exists", bin) + } + } + } + return "" +} + +// hookBinary is the absolute path a hook command starts with, unquoted, or "" +// when it starts with something resolved at run time (npx, a bare logos). +func hookBinary(cmd string) string { + var bin string + if strings.HasPrefix(cmd, "'") { + end := strings.Index(cmd[1:], "'") + if end < 0 { + return "" + } + bin = cmd[1 : end+1] + } else { + bin, _, _ = strings.Cut(cmd, " ") + } + if !filepath.IsAbs(bin) { + return "" + } + return bin +} diff --git a/internal/setup/hookcheck_test.go b/internal/setup/hookcheck_test.go new file mode 100644 index 00000000..cd34c8e7 --- /dev/null +++ b/internal/setup/hookcheck_test.go @@ -0,0 +1,71 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeHooks(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hooks.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestDoctorNamesACodexHooksFileCodexRefuses(t *testing.T) { + path := writeHooks(t, `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"/bin/sh hook codex session-start"}]}]}}`) + if got := hookFileProblem(path, "codex"); !strings.Contains(got, "version") { + t.Fatalf("a versioned Codex file was not named: %q", got) + } +} + +func TestDoctorNamesAHookThatStartsTheServerInstead(t *testing.T) { + path := writeHooks(t, `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"/bin/sh mcp serve hook codex session-start"}]}]}}`) + if got := hookFileProblem(path, "codex"); !strings.Contains(got, "MCP server") { + t.Fatalf("an mcp serve hook was not named: %q", got) + } +} + +func TestDoctorNamesACodexHookInCursorsShape(t *testing.T) { + path := writeHooks(t, `{"hooks":{"sessionStart":[{"command":"/bin/sh hook codex session-start"}]}}`) + if got := hookFileProblem(path, "codex"); !strings.Contains(got, "never runs") { + t.Fatalf("a flat Codex hook was not named: %q", got) + } +} + +func TestDoctorNamesAHookWhoseBinaryIsGone(t *testing.T) { + gone := filepath.Join(t.TempDir(), "logos") + path := writeHooks(t, `{"version":1,"hooks":{"sessionStart":[{"command":"'`+gone+`' hook cursor session-start"}]}}`) + if got := hookFileProblem(path, "cursor"); !strings.Contains(got, gone) { + t.Fatalf("a hook on a deleted binary was not named: %q", got) + } +} + +func TestDoctorIsQuietAboutHooksThatWillRun(t *testing.T) { + cases := map[string]string{ + "codex": `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"/bin/sh hook codex session-start"}]}]}}`, + "cursor": `{"version":1,"hooks":{"sessionStart":[{"command":"npx -y logos hook cursor session-start"}]}}`, + } + for host, body := range cases { + if got := hookFileProblem(writeHooks(t, body), host); got != "" { + t.Errorf("%s: a working hook was reported: %q", host, got) + } + } + if got := hookFileProblem(filepath.Join(t.TempDir(), "absent.json"), "codex"); got != "" { + t.Errorf("no hooks file was reported: %q", got) + } +} + +func TestSetupLeavesNothingForTheHookCheckToReport(t *testing.T) { + path := writeHooks(t, `{"version":1,"hooks":{"sessionStart":[{"command":"/bin/sh mcp serve hook codex session-start"}]}}`) + if _, err := installHook(path, "codex", []string{"/bin/sh"}); err != nil { + t.Fatal(err) + } + if got := hookFileProblem(path, "codex"); got != "" { + t.Fatalf("setup left a problem behind: %q\n%s", got, mustRead(t, path)) + } +} diff --git a/internal/setup/hooks.go b/internal/setup/hooks.go index ef5c7f5c..43226c3a 100644 --- a/internal/setup/hooks.go +++ b/internal/setup/hooks.go @@ -48,6 +48,16 @@ type hookFile struct { rest map[string]json.RawMessage // everything in the file, ours included } +// unversion takes the version out of a file that is Codex's. Codex refuses a +// hooks file with any top-level key but description and hooks ("unknown field +// `version`") and then runs none of its hooks, the user's included — and every +// setup before 0.5.5 wrote Cursor's versioned shape into it, so keeping the key +// on the way through left Codex as broken as it found it. +func (f *hookFile) unversion() { + f.versioned = false + delete(f.rest, "version") +} + // A session-start entry is kept as the object the file held, not as a struct of // the one field logos reads. Hosts put more on an entry than a command — a // timeout, a name, a matcher — and a struct with a single field silently @@ -116,6 +126,7 @@ func installHook(path, host string, argv []string) (Outcome, error) { want := logosEntryFor(host, argv) var had bool if host == "codex" { + f.unversion() had, err = installCodexHook(path, &f, want) } else { f.versioned = true @@ -429,6 +440,9 @@ func removeHook(path string) (bool, string, error) { if !flat && !nested { return false, "", nil } + if nested { + f.unversion() + } backup, err := backupFile(path, true) if err != nil { return false, "", err diff --git a/internal/setup/hooks_test.go b/internal/setup/hooks_test.go index 0ec971a9..bd004f6c 100644 --- a/internal/setup/hooks_test.go +++ b/internal/setup/hooks_test.go @@ -452,3 +452,55 @@ func TestAConfigWithANullServersBlockGetsLogos(t *testing.T) { t.Errorf("the user's other key was lost: %v", cfg) } } + +// Codex 0.164 refuses a hooks file with any top-level key but description and +// hooks — "unknown field `version`" — and then runs none of the hooks in it, +// the user's included. Setups before 0.5.5 wrote Cursor's versioned shape +// there, and moving the entry into Codex's shape kept the key, so re-running +// setup left Codex exactly as broken as before. +func TestMovingAnEarlierCodexHookDropsTheVersionCodexRefuses(t *testing.T) { + home := fakeHome(t, ".codex") + path := filepath.Join(home, ".codex", "hooks.json") + old := `{"hooks":{"sessionStart":[{"command":"/opt/homebrew/opt/logos-mcp/bin/logos mcp serve hook codex session-start"}]},"version":1}` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if _, err := installHook(path, "codex", []string{"/usr/local/bin/logos"}); err != nil { + t.Fatal(err) + } + if _, has := readJSON(t, path)["version"]; has { + t.Errorf("Codex's hooks file still has a version, which Codex refuses:\n%s", mustRead(t, path)) + } + if cmds := codexHookCommands(t, path); len(cmds) != 1 { + t.Errorf("SessionStart = %q, want logos's hook", cmds) + } +} + +// Uninstalling has to leave a file Codex can read too: the user's own hooks +// are still in it, and a version left behind stops all of them. +func TestUninstallLeavesCodexAHooksFileItCanRead(t *testing.T) { + home := fakeHome(t, ".codex") + path := filepath.Join(home, ".codex", "hooks.json") + old := `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"mine.sh"},{"type":"command","command":"/usr/local/bin/logos hook codex session-start"}]}]}}` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := removeHook(path); err != nil { + t.Fatal(err) + } + if _, has := readJSON(t, path)["version"]; has { + t.Errorf("uninstall left a version in Codex's hooks file:\n%s", mustRead(t, path)) + } + if cmds := codexHookCommands(t, path); len(cmds) != 1 || cmds[0] != "mine.sh" { + t.Errorf("SessionStart = %q, want only the user's handler", cmds) + } +} + +func mustRead(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/spend/price.go b/internal/spend/price.go new file mode 100644 index 00000000..f5719634 --- /dev/null +++ b/internal/spend/price.go @@ -0,0 +1,60 @@ +package spend + +import "strings" + +// PriceSource is shown beside every dollar figure. The table is built in and +// never fetched: nothing leaves the machine, and a price looked up on each run +// would be a network call for a number that changes a few times a year. The +// date says how old it is, so a reader can tell a stale table from a cheap +// month. +const PriceSource = "Anthropic list prices as of 6 Oct 2026, standard tier, before any discount" + +// Price is a model's list price in dollars per million tokens. Cache writes +// are priced from Input by the published multipliers — 1.25× for the 5-minute +// cache, 2× for the 1-hour one — so only the read rate, which differs by +// model, is listed. +type Price struct { + Input, Output, CacheRead float64 + // Long is the rate card for a request whose prompt passes LongAfter + // tokens, for a model with two (Haiku 5.5). Zero when there is one card. + Long *Price + LongAfter int64 +} + +// prices holds only what was read from a published source. A model missing +// here is shown with its tokens and "no list price", never with a guess: a +// dashboard that invents a figure is worse than one that leaves it blank. +var prices = map[string]Price{ + "claude-fable-5-1": {Input: 10, Output: 50, CacheRead: 0.25}, + "claude-opus-5-5": {Input: 4, Output: 20, CacheRead: 0.20}, + "claude-opus-5": {Input: 5, Output: 25, CacheRead: 0.50}, + "claude-sonnet-5-5": {Input: 2, Output: 10, CacheRead: 0.20}, + "claude-sonnet-5": {Input: 2, Output: 10, CacheRead: 0.20}, + "claude-haiku-5-5": {Input: 0.10, Output: 0.50, CacheRead: 0.01, + Long: &Price{Input: 0.50, Output: 2.50, CacheRead: 0.05}, LongAfter: 100_000}, +} + +// Cost prices one request. ok is false for a model with no list price. +func Cost(model string, u Usage) (usd float64, ok bool) { + p, ok := prices[baseModel(model)] + if !ok { + return 0, false + } + if p.Long != nil && u.Prompt() > p.LongAfter { + p = *p.Long + } + return (float64(u.Input)*p.Input + + float64(u.CacheWrite5m)*p.Input*1.25 + + float64(u.CacheWrite1h)*p.Input*2 + + float64(u.CacheRead)*p.CacheRead + + float64(u.Output)*p.Output) / 1e6, true +} + +// baseModel drops a context-size tag such as "[1m]", which a host may append +// to the id it records; the price is the model's either way. +func baseModel(m string) string { + if i := strings.IndexByte(m, '['); i > 0 { + m = m[:i] + } + return strings.TrimSpace(m) +} diff --git a/internal/spend/spend.go b/internal/spend/spend.go new file mode 100644 index 00000000..84fb76e8 --- /dev/null +++ b/internal/spend/spend.go @@ -0,0 +1,436 @@ +// Package spend reads what coding agents on this machine spent, from the +// transcripts their hosts already keep: Claude Code's under +// ~/.claude/projects and Codex's under ~/.codex/sessions. It is the one figure +// on `logos stats --usage` that is not Logos's own ledger, and it is labelled +// so: tokens the host recorded, priced from a built-in table. +// +// It reads and never writes. The transcripts are the host's files, and the +// totals are recomputed on every run, so there is no cache to fall out of +// step with them and nothing for an index rebuild to lose. +package spend + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Coder8124/logos/internal/transcript" + "github.com/Coder8124/logos/internal/usage" +) + +// Usage is token counts as a request was billed: uncached input, the two +// cache-write durations, cache reads, and output. +type Usage struct { + Input, CacheWrite5m, CacheWrite1h, CacheRead, Output int64 +} + +// Prompt is everything the model read on a request — its context size. +func (u Usage) Prompt() int64 { return u.Input + u.CacheWrite5m + u.CacheWrite1h + u.CacheRead } + +// Total is every token, read and written. +func (u Usage) Total() int64 { return u.Prompt() + u.Output } + +func (u *Usage) add(o Usage) { + u.Input += o.Input + u.CacheWrite5m += o.CacheWrite5m + u.CacheWrite1h += o.CacheWrite1h + u.CacheRead += o.CacheRead + u.Output += o.Output +} + +// Bucket is one row of a breakdown. Unpriced counts the tokens of models with +// no list price, so a total in dollars can say how much it leaves out. +type Bucket struct { + Usage + USD float64 + Unpriced int64 +} + +func (b *Bucket) add(model string, u Usage) { + b.Usage.add(u) + if usd, ok := Cost(model, u); ok { + b.USD += usd + } else { + b.Unpriced += u.Total() + } +} + +// Session is one agent session's largest context, for the session-size panel. +type Session struct { + Host, Model string + Start int64 + Peak int64 +} + +// Report is the window read. +type Report struct { + From time.Time + Total Bucket + Models, Projects map[string]*Bucket + // Days is keyed "2006-01-02" in local time. + Days map[string]*Bucket + Sessions []Session + // Files is how many transcripts were read and Unreadable how many lines + // in them could not be parsed, so a total missing lines says so. Skipped + // is the files that could not be read to the end — removed while the walk + // ran, or holding a line past the scanner's limit — whose lines up to the + // failure are counted and the rest are not. + Files, Unreadable, Skipped int + // Hosts names the hosts whose transcripts were found. + Hosts []string +} + +func newReport(from time.Time) *Report { + return &Report{From: from, Models: map[string]*Bucket{}, Projects: map[string]*Bucket{}, Days: map[string]*Bucket{}} +} + +func (r *Report) add(ts int64, model, project string, u Usage) { + r.Total.add(model, u) + for _, kv := range []struct { + m map[string]*Bucket + k string + }{{r.Models, model}, {r.Projects, project}, {r.Days, time.Unix(ts, 0).Format("2006-01-02")}} { + b := kv.m[kv.k] + if b == nil { + b = &Bucket{} + kv.m[kv.k] = b + } + b.add(model, u) + } +} + +// Read totals what Claude Code and Codex recorded from from onward, for one +// project or, when project is "", all of them. A host whose directory is +// missing is skipped. A file that cannot be read to the end is counted in +// Skipped rather than failing the report: one host file deleted mid-walk +// should not blank a month of figures, and the count says what is missing. +func Read(from time.Time, project string) (*Report, error) { + r := newReport(from) + if root := transcript.ClaudeProjectsRoot(); root != "" { + r.Hosts = append(r.Hosts, "Claude Code") + if err := readClaude(r, root, project); err != nil { + return nil, err + } + } + if root := transcript.CodexSessionsRoot(); root != "" { + r.Hosts = append(r.Hosts, "Codex") + if err := readCodex(r, root, project); err != nil { + return nil, err + } + } + sort.Slice(r.Sessions, func(i, j int) bool { return r.Sessions[i].Start < r.Sessions[j].Start }) + return r, nil +} + +// transcripts lists the .jsonl files under root changed since from. A file +// last written before the window cannot hold a line inside it, and skipping +// it unread is what keeps a month's dashboard off the whole history's bytes. +func transcripts(root string, from time.Time) ([]string, error) { + var out []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if p == root { + return err + } + return nil // an unreadable subdirectory is the host's, not ours to fail on + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".jsonl") { + return nil + } + if info, err := d.Info(); err == nil && info.ModTime().Before(from) { + return nil + } + out = append(out, p) + return nil + }) + return out, err +} + +// unknownModel holds tokens whose host did not name the model. They are +// counted, unpriced, rather than dropped, so the token total stays whole. +const unknownModel = "(model not recorded)" + +func scanLines(path string, each func(n int, b []byte)) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + sc := bufio.NewScanner(f) + // A transcript line carries whole tool outputs and can run to megabytes. + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for n := 1; sc.Scan(); n++ { + each(n, sc.Bytes()) + } + return sc.Err() +} + +// parseTS reports ok=false for a missing or malformed timestamp, which the +// readers count as unreadable: read as zero, it fell before every window and +// the line vanished with no count. +func parseTS(s string) (int64, bool) { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return 0, false + } + return t.Unix(), true +} + +// projectOf names a session's project by its folder, as the importers do. +// A host's own worktree — /.claude/worktrees/agent-a2ed… — is that +// project's work: named by its folder, every subagent became a project of +// its own and crowded the real ones out of the top five. +func projectOf(cwd string) string { + cwd = filepath.Clean(cwd) + if cwd == "" || cwd == "." || cwd == string(filepath.Separator) { + return "(no project)" + } + parts := strings.Split(filepath.ToSlash(cwd), "/") + for i := len(parts) - 1; i >= 2; i-- { + if parts[i] == "worktrees" && strings.HasPrefix(parts[i-1], ".") && parts[i-2] != "" { + return parts[i-2] + } + } + return filepath.Base(cwd) +} + +func wanted(project, want string) bool { + return want == "" || usage.SameProject(project, want) +} + +type claudeLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Cwd string `json:"cwd"` + SessionID string `json:"sessionId"` + RequestID string `json:"requestId"` + Message struct { + ID string `json:"id"` + Model string `json:"model"` + Usage *struct { + Input int64 `json:"input_tokens"` + CacheCreation int64 `json:"cache_creation_input_tokens"` + CacheRead int64 `json:"cache_read_input_tokens"` + Output int64 `json:"output_tokens"` + Split struct { + H1 int64 `json:"ephemeral_1h_input_tokens"` + M5 int64 `json:"ephemeral_5m_input_tokens"` + } `json:"cache_creation"` + } `json:"usage"` + } `json:"message"` +} + +type claudeReq struct { + ts int64 + model, project string + session string + u Usage +} + +// readClaude counts each API request once. Claude Code writes one transcript +// line per content block of a response, each repeating the request's usage, +// so summing lines counts a three-block reply three times; and a resumed +// session copies earlier lines into a new file. The message id and request id +// together name the request, wherever it was written, and the last line +// written for it carries its final output count. +func readClaude(r *Report, root, project string) error { + files, err := transcripts(root, r.From) + if err != nil { + return err + } + from := r.From.Unix() + reqs := map[string]claudeReq{} + for _, path := range files { + r.Files++ + err := scanLines(path, func(n int, b []byte) { + if !bytes.Contains(b, []byte(`"usage"`)) || !bytes.Contains(b, []byte(`"assistant"`)) { + return + } + var ln claudeLine + if json.Unmarshal(b, &ln) != nil { + r.Unreadable++ + return + } + m := ln.Message + if ln.Type != "assistant" || m.Usage == nil || m.Model == "" || strings.HasPrefix(m.Model, "<") { + return // "" is Claude Code's own placeholder, not a request + } + ts, ok := parseTS(ln.Timestamp) + if !ok { + r.Unreadable++ + return + } + p := projectOf(ln.Cwd) + if ts < from || !wanted(p, project) { + return + } + u := Usage{Input: m.Usage.Input, CacheRead: m.Usage.CacheRead, Output: m.Usage.Output} + if s := m.Usage.Split; s.H1+s.M5 > 0 { + u.CacheWrite1h, u.CacheWrite5m = s.H1, s.M5 + } else { + u.CacheWrite5m = m.Usage.CacheCreation + } + // With no message id, two requests in one second of one file + // would share a timestamp key and the second would replace the + // first; the line number keeps them apart. + key := m.ID + "|" + ln.RequestID + if m.ID == "" { + key = fmt.Sprintf("%s|%d", path, n) + } + // A line with no session id still belongs to its file's session; + // keyed on "", every such line in every file was one session. + sess := ln.SessionID + if sess == "" { + sess = path + } + reqs[key] = claudeReq{ts: ts, model: baseModel(m.Model), project: p, session: sess, u: u} + }) + if err != nil { + r.Skipped++ + } + } + peaks := map[string]*Session{} + for _, q := range reqs { + r.add(q.ts, q.model, q.project, q.u) + s := peaks[q.session] + if s == nil { + s = &Session{Host: "Claude Code", Start: q.ts} + peaks[q.session] = s + } + if q.ts < s.Start { + s.Start = q.ts + } + if q.u.Prompt() > s.Peak { + s.Peak, s.Model = q.u.Prompt(), q.model + } + } + for _, s := range peaks { + r.Sessions = append(r.Sessions, *s) + } + return nil +} + +type codexLine struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload struct { + Type string `json:"type"` + Cwd string `json:"cwd"` + Model string `json:"model"` + Info *struct { + Total codexUsage `json:"total_token_usage"` + Last codexUsage `json:"last_token_usage"` + } `json:"info"` + } `json:"payload"` +} + +type codexUsage struct { + Input int64 `json:"input_tokens"` + Cached int64 `json:"cached_input_tokens"` + CacheWrite int64 `json:"cache_write_input_tokens"` + Output int64 `json:"output_tokens"` +} + +// readCodex counts each file's growth in total_token_usage. Codex repeats a +// token_count event when only its rate limits changed, so adding every +// last_token_usage double-counts; the running total moves only when tokens +// were spent. Its input count includes the cached part, unlike Claude's, so +// the cached and written parts are taken out to leave uncached input. +func readCodex(r *Report, root, project string) error { + files, err := transcripts(root, r.From) + if err != nil { + return err + } + from := r.From.Unix() + for _, path := range files { + r.Files++ + var model, cwd string + var prev codexUsage + sess := Session{Host: "Codex"} + err := scanLines(path, func(_ int, b []byte) { + isCtx := bytes.Contains(b, []byte(`"turn_context"`)) || bytes.Contains(b, []byte(`"session_meta"`)) + if !isCtx && !bytes.Contains(b, []byte(`"token_count"`)) { + return + } + var ln codexLine + if json.Unmarshal(b, &ln) != nil { + r.Unreadable++ + return + } + switch { + case ln.Type == "turn_context" || ln.Type == "session_meta": + if ln.Payload.Model != "" { + model = ln.Payload.Model + } + if ln.Payload.Cwd != "" { + cwd = ln.Payload.Cwd + } + case ln.Payload.Type == "token_count" && ln.Payload.Info != nil: + t := ln.Payload.Info.Total + d := codexUsage{t.Input - prev.Input, t.Cached - prev.Cached, t.CacheWrite - prev.CacheWrite, t.Output - prev.Output} + prev = t + if d.Input+d.Output <= 0 { + return + } + ts, ok := parseTS(ln.Timestamp) + if !ok { + r.Unreadable++ + return + } + p := projectOf(cwd) + if ts < from || !wanted(p, project) { + return + } + model := model + if model == "" { + model = unknownModel + } + u := Usage{Input: max(d.Input-d.Cached-d.CacheWrite, 0), CacheRead: d.Cached, CacheWrite5m: d.CacheWrite, Output: d.Output} + r.add(ts, model, p, u) + if sess.Start == 0 { + sess.Start = ts + } + if last := ln.Payload.Info.Last.Input; last > sess.Peak { + sess.Peak, sess.Model = last, model + } + } + }) + if err != nil { + r.Skipped++ + } + if sess.Peak > 0 { + r.Sessions = append(r.Sessions, sess) + } + } + return nil +} + +// Top is the n largest rows of a breakdown, by dollars and then by tokens, +// largest first. +func Top(m map[string]*Bucket, n int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + a, b := m[keys[i]], m[keys[j]] + if a.USD != b.USD { + return a.USD > b.USD + } + if a.Total() != b.Total() { + return a.Total() > b.Total() + } + return keys[i] < keys[j] + }) + if len(keys) > n { + keys = keys[:n] + } + return keys +} diff --git a/internal/spend/spend_test.go b/internal/spend/spend_test.go new file mode 100644 index 00000000..9b78c96d --- /dev/null +++ b/internal/spend/spend_test.go @@ -0,0 +1,273 @@ +package spend + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Coder8124/logos/internal/transcript" +) + +// hosts points both readers at fresh directories and returns them. A host +// left without files still counts as found, which is what a real machine +// with Claude Code installed but unused this month looks like. +func hosts(t *testing.T) (claude, codex string) { + t.Helper() + claude, codex = t.TempDir(), t.TempDir() + t.Setenv(transcript.LogosClaudeProjectsEnv, claude) + t.Setenv(transcript.LogosCodexSessionsEnv, codex) + return claude, codex +} + +func write(t *testing.T, path string, lines ...string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +func claudeReqLine(ts time.Time, cwd, session, id, req, model string, in, w5, w1, read, out int64) string { + return fmt.Sprintf(`{"type":"assistant","timestamp":%q,"cwd":%q,"sessionId":%q,"requestId":%q,"message":{"id":%q,"model":%q,"usage":{"input_tokens":%d,"cache_creation_input_tokens":%d,"cache_read_input_tokens":%d,"output_tokens":%d,"cache_creation":{"ephemeral_5m_input_tokens":%d,"ephemeral_1h_input_tokens":%d}}}}`, + stamp(ts), cwd, session, req, id, model, in, w5+w1, read, out, w5, w1) +} + +func near(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestARequestWrittenAsSeveralLinesIsCountedOnce(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + // One response of three content blocks, then the same request copied + // into a resumed session's file: still one request. + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 10), + claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 20), + claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 30)) + write(t, filepath.Join(claude, "-p", "b.jsonl"), + claudeReqLine(now, "/w/proj", "s2", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 30)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if r.Total.Input != 1000 || r.Total.Output != 30 { + t.Fatalf("total = %+v, want one request of 1000 in and 30 out", r.Total.Usage) + } +} + +func TestClaudeCodesSyntheticMessagesAreNotRequests(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "", 5000, 0, 0, 0, 5)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if r.Total.Total() != 0 { + t.Fatalf("a line was counted: %+v", r.Total.Usage) + } +} + +func TestEachTokenKindIsPricedAtItsOwnRate(t *testing.T) { + // claude-opus-5-5: $4 in, $20 out, $0.20 cache read; writes 1.25× and 2× input. + usd, ok := Cost("claude-opus-5-5[1m]", Usage{Input: 1e6, CacheWrite5m: 1e6, CacheWrite1h: 1e6, CacheRead: 1e6, Output: 1e6}) + if !ok || !near(usd, 4+5+8+0.20+20) { + t.Fatalf("Cost = %v, %v; want 37.20", usd, ok) + } +} + +func TestAHaikuPromptPastTheLongThresholdUsesTheLongRateCard(t *testing.T) { + short, _ := Cost("claude-haiku-5-5", Usage{Input: 100_000, Output: 1e6}) + long, _ := Cost("claude-haiku-5-5", Usage{Input: 100_001, Output: 1e6}) + if !near(short, 0.01+0.50) { + t.Fatalf("at the threshold = %v, want the standard card (0.51)", short) + } + if !near(long, 100_001*0.50/1e6+2.50) { + t.Fatalf("past the threshold = %v, want the long card", long) + } +} + +func TestAModelWithNoListPriceKeepsItsTokensButNoDollars(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "s1", "m1", "r1", "flint", 400, 0, 0, 0, 100), + claudeReqLine(now, "/w/proj", "s1", "m2", "r2", "claude-sonnet-5", 1e6, 0, 0, 0, 0)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if f := r.Models["flint"]; f == nil || f.USD != 0 || f.Unpriced != 500 { + t.Fatalf("flint = %+v, want 500 unpriced tokens and no dollars", f) + } + if !near(r.Total.USD, 2) || r.Total.Unpriced != 500 { + t.Fatalf("total = $%v with %d unpriced, want $2 and 500", r.Total.USD, r.Total.Unpriced) + } +} + +func TestASubagentWorktreeCountsAsTheProjectItWorksOn(t *testing.T) { + for cwd, want := range map[string]string{ + "/u/brain/.claude/worktrees/agent-a2ed589c": "brain", + "/u/brain": "brain", + "/u/worktrees/brain": "brain", + "": "(no project)", + } { + if got := projectOf(cwd); got != want { + t.Errorf("projectOf(%q) = %q, want %q", cwd, got, want) + } + } +} + +func TestARequestBeforeTheWindowIsLeftOut(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now.Add(-48*time.Hour), "/w/proj", "s1", "m1", "r1", "claude-sonnet-5", 999, 0, 0, 0, 0), + claudeReqLine(now, "/w/proj", "s1", "m2", "r2", "claude-sonnet-5", 1, 0, 0, 0, 0)) + r, err := Read(now.Add(-24*time.Hour), "") + if err != nil { + t.Fatal(err) + } + if r.Total.Input != 1 { + t.Fatalf("input = %d, want only the request inside the window", r.Total.Input) + } +} + +func TestAProjectFilterKeepsOnlyThatProject(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/alpha", "s1", "m1", "r1", "claude-sonnet-5", 7, 0, 0, 0, 0), + claudeReqLine(now, "/w/beta", "s2", "m2", "r2", "claude-sonnet-5", 9, 0, 0, 0, 0)) + r, err := Read(now.Add(-time.Hour), "alpha") + if err != nil { + t.Fatal(err) + } + if r.Total.Input != 7 || len(r.Projects) != 1 { + t.Fatalf("total %d over %v, want alpha's 7 alone", r.Total.Input, r.Projects) + } +} + +func codexCount(ts time.Time, in, cached, out, lastIn int64) string { + return fmt.Sprintf(`{"timestamp":%q,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":%d,"cached_input_tokens":%d,"output_tokens":%d},"last_token_usage":{"input_tokens":%d}}}}`, + stamp(ts), in, cached, out, lastIn) +} + +func TestCodexCountsGrowthNotEveryRepeatedTokenCount(t *testing.T) { + _, codex := hosts(t) + now := time.Now() + write(t, filepath.Join(codex, "2026", "rollout.jsonl"), + fmt.Sprintf(`{"timestamp":%q,"type":"session_meta","payload":{"cwd":"/w/proj"}}`, stamp(now)), + fmt.Sprintf(`{"timestamp":%q,"type":"turn_context","payload":{"cwd":"/w/proj","model":"gpt-6-luna"}}`, stamp(now)), + codexCount(now, 1000, 600, 50, 1000), + codexCount(now, 1000, 600, 50, 1000), // a rate-limit refresh: nothing spent + codexCount(now, 3000, 2400, 80, 2000)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + // 3000 in of which 2400 cached: 600 uncached, 2400 read, 80 out. + got := r.Total.Usage + if got.Input != 600 || got.CacheRead != 2400 || got.Output != 80 { + t.Fatalf("total = %+v, want 600 uncached, 2400 cached, 80 out", got) + } + if len(r.Sessions) != 1 || r.Sessions[0].Peak != 2000 || r.Sessions[0].Model != "gpt-6-luna" { + t.Fatalf("sessions = %+v, want one gpt-6-luna session peaking at 2000", r.Sessions) + } +} + +func TestAMissingHostDirectoryIsSkippedNotAnError(t *testing.T) { + t.Setenv(transcript.LogosClaudeProjectsEnv, filepath.Join(t.TempDir(), "absent")) + t.Setenv(transcript.LogosCodexSessionsEnv, filepath.Join(t.TempDir(), "absent")) + r, err := Read(time.Now().Add(-time.Hour), "") + if err != nil || len(r.Hosts) != 0 { + t.Fatalf("Read = %+v, %v; want no hosts and no error", r, err) + } +} + +func TestATranscriptThatCannotBeOpenedIsCountedNotFatal(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 10)) + // A host file gone between the walk and the read: a dangling link is the + // same thing without the race. + if err := os.Symlink(filepath.Join(claude, "gone"), filepath.Join(claude, "-p", "b.jsonl")); err != nil { + t.Skip("symlinks unavailable:", err) + } + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatalf("one unreadable file failed the whole report: %v", err) + } + if r.Skipped != 1 || r.Total.Input != 1000 { + t.Fatalf("skipped = %d, input = %d; want 1 file skipped and the other's 1000 counted", r.Skipped, r.Total.Input) + } +} + +func TestALineWithABadTimestampIsCountedUnreadable(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + line := strings.Replace(claudeReqLine(now, "/w/proj", "s1", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 10), stamp(now), "yesterday-ish", 1) + write(t, filepath.Join(claude, "-p", "a.jsonl"), line) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if r.Unreadable != 1 { + t.Fatalf("unreadable = %d, want the bad-timestamp line counted", r.Unreadable) + } +} + +func TestCodexTokensBeforeAnyModelIsNamedAreKeptUnpriced(t *testing.T) { + _, codex := hosts(t) + now := time.Now() + write(t, filepath.Join(codex, "2026", "rollout.jsonl"), + codexCount(now, 1000, 0, 50, 1000)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + b := r.Models[unknownModel] + if b == nil || b.Total() != 1050 || r.Total.Unpriced != 1050 { + t.Fatalf("models = %v, unpriced = %d; want 1050 tokens under %q", r.Models, r.Total.Unpriced, unknownModel) + } +} + +func TestClaudeLinesWithNoSessionIdAreOneSessionPerFile(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "", "msg_1", "req_1", "claude-opus-5-5", 1000, 0, 0, 0, 10)) + write(t, filepath.Join(claude, "-p", "b.jsonl"), + claudeReqLine(now, "/w/proj", "", "msg_2", "req_2", "claude-opus-5-5", 2000, 0, 0, 0, 10)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if len(r.Sessions) != 2 { + t.Fatalf("sessions = %d, want one per file", len(r.Sessions)) + } +} + +func TestTwoRequestsWithNoMessageIdInOneSecondAreBothCounted(t *testing.T) { + claude, _ := hosts(t) + now := time.Now() + write(t, filepath.Join(claude, "-p", "a.jsonl"), + claudeReqLine(now, "/w/proj", "s1", "", "", "claude-opus-5-5", 1000, 0, 0, 0, 10), + claudeReqLine(now, "/w/proj", "s1", "", "", "claude-opus-5-5", 2000, 0, 0, 0, 10)) + r, err := Read(now.Add(-time.Hour), "") + if err != nil { + t.Fatal(err) + } + if r.Total.Input != 3000 { + t.Fatalf("input = %d, want both requests' 3000", r.Total.Input) + } +} diff --git a/internal/transcript/hostdir_roots.go b/internal/transcript/hostdir_roots.go new file mode 100644 index 00000000..e07bff98 --- /dev/null +++ b/internal/transcript/hostdir_roots.go @@ -0,0 +1,10 @@ +package transcript + +// ClaudeProjectsRoot and CodexSessionsRoot are where each host keeps its +// transcripts, as every reader here resolves them — moved config directories +// and the test overrides included — so a reader outside this package (the +// spend dashboard) cannot look somewhere the importers do not. Empty when the +// host's directory is not there. +func ClaudeProjectsRoot() string { return claudeCodeReader{}.root() } + +func CodexSessionsRoot() string { return codexReader{}.root() }