Compare commits

...
26 Commits
Author SHA1 Message Date
Gnanam 6174b20226 zeroline: remove the ZERO boot splash — launch straight to the empty state
The animated ZERO-wordmark boot splash showed for ~2s before the home; per the
request it's gone. Dropped the splash render branch + the m.booted flag/bootFrames
gating, and removed the now-dead RenderBoot + wordmark + newCanvasStyles (its last
users) and the --boot snapshot flag. The frame ticker stays (spinners); showSplash
stays (drives the empty state + placeholder). startup.go (default shell) untouched.
build/vet/test/windows/deadcode green.
2026-06-09 20:44:06 +05:30
Gnanam 4388807eee zeroline: home/empty state now matches the spec (chrome + centered 0)
The home was a separate full-screen Zen page (giant ZERO wordmark, no bars),
which doesn't match the spec: there the empty state lives INSIDE the chat layout
(title bar + status bar + composer) with a small 0 mark + tagline + model hint +
chips centered in the stream. Reworked to that: RenderChat renders an emptyState
in its body when there are no rows/stream; added zeroMark (small 0) + ChatData
.Chips/.ChipIndex; routed the live home + snapshot --page home through RenderChat;
removed the now-unused RenderHome + HomeData. emptyState is frame-exact (centers +
crops to h rows). Boot splash keeps the ZERO wordmark. build/vet/test/windows/
deadcode green.
2026-06-09 20:36:07 +05:30
Gnanam 28d55e3fd8 zeroline: remove the abandoned colorizeDiff/diffLine colorizer
The old unified-diff colorizer (colorizeDiff free func + method, diffLine,
diffMaxLines) was replaced by the bordered diff card (diffBody) in Phase 4 and has
no production caller (deadcode-confirmed); only its own tests referenced it.
Removed it + the tests that exclusively covered it (TestColorizeDiff*) + the
forceColor helper and its lipgloss/termenv imports. Kept looksLikeDiff (used by
the card) + TestLooksLikeDiff. Diff card (diffBody) + all snapshots unchanged;
build/vet/test/goldens green.
2026-06-09 20:12:17 +05:30
Gnanam 16554508f5 zeroline: remove the abandoned glamour markdown renderer
FIX 1 made assistant prose plain (renderSay), so the glamour markdown path is
dead: delete markdown.go (renderMarkdown/markdownRenderer/themeStyleConfig/sp/hex/
boolp + the renderer cache) and styles.renderAssistantMarkdown/renderAssistantPlain
in render.go (provably unreachable from main per deadcode). Removed the tests that
exclusively covered them (TestRenderMarkdown*, TestMarkdownRenderer*,
TestAssistantMarkdownClips*) + the now-orphaned glamour import. glamour is no
longer imported anywhere (can be go-mod-tidied separately). Kept looksLikeDiff +
its test and the plain/streaming assistant tests. build/vet/test/goldens green.
2026-06-09 20:09:11 +05:30
Gnanam 84ff761544 zeroline: remove provably-dead helpers orphaned by the redesign
staticcheck U1000-confirmed unused (no production OR test references): styles.block
(old bg-space caret, replaced by the ▌ glyph), styles.renderAssistant (dispatcher
— assistant is now plain renderSay), styles.renderCodeBlock + styles.codeLine (old
tool-output block, replaced by tool cards), resultSummary + plural + countNonEmpty
+ countBodyLines (old one-line result summaries, replaced by cards). No test
touched; U1000 clean afterward; build/vet/test/goldens green.
2026-06-09 20:03:17 +05:30
Gnanam a503b3a0ba zeroline: color the resolved-permission line by outcome (.perm-resolved)
The recorded permission line was always amber ⚠ regardless of decision; per the
spec it now reflects the outcome — allow/always green ✓, deny red ✗, pending ⚠
(parsed from the action word in the row text). +regression test.
2026-06-09 19:56:36 +05:30
Gnanam df6194544b zeroline: fix read_file card double line-numbering
The read_file tool already emits a 'File: … (N lines)' header and numbers each
line ('N | …'), but the read card re-numbered them — producing a doubled gutter
like '3   1 | import random' (seen in the live TUI). New readBody drops the
redundant File: header (the card head shows the path) and keeps the tool's own
numbering; numberedBody stays for the fallback/write_file body. Snapshot --tool
read now uses the real read_file output format. +regression test.
2026-06-09 19:51:34 +05:30
Gnanam 27b377186a zeroline: polish to match the spec — plain assistant prose, bordered chips, clean composer
FIX 1: assistant prose (.blk-say) renders as plain MUTED text (renderSay), no
panel/background — was routed through glamour (near-ink on a code block bg). Only
the final answer (.blk-final) keeps the accent rail + ink.
FIX 2: each home suggestion is its own bordered rounded box (.chip: panel bg,
accent ❯ + label); the selected chip uses a thick accent border (terminal analog
of the spec's accent hover border, visible without color). Was plain stacked rows.
FIX 3: composer shows the spec placeholder per surface (home 'describe a task for
zero…', chat 'message zero…') via a dynamic placeholder; the home composer box now
renders the accent ❯ prompt too. No %% artifact existed on this branch.
TDD: backtick-survival (plain say), chipBox bordered+selected, home separate
boxes, placeholder clean. No golden files exist (regenerate step is a no-op).
2026-06-09 19:39:09 +05:30
Gnanam 31cab0d836 zeroline: fix Phase 6-9 adversarial-review findings
- High: the sessions-drawer modal block no longer swallows Ctrl+C or a pending
  permission's keyboard (a/A/d) — both now take precedence over the drawer.
- High: ctrl+s on the home page no longer opens a drawer nothing renders (which
  froze input); it's a no-op until the chat page.
- Medium: composer clips the input + bounds input.Width (zeroline) so a long line
  can't push the run/stop button off the frame; drawer id/when row clipped to the
  panel width (long raw-timestamp fallback no longer overflows).
- Nit: stop button gets the spec panel background.
- Tests: Ctrl+C-through-drawer, no-drawer-on-home, composer-button-not-clipped.
Deferred (noted): TEXT|JSON toggle hidden at very narrow widths; drawer Enter-to-
resume action.
2026-06-09 18:41:30 +05:30
Gnanam c8bdbad5a1 zeroline: gofmt pre-existing test files in touched packages (keep gofmt -l clean) 2026-06-09 18:21:16 +05:30
Gnanam 5b685861f1 zeroline: complete --snapshot flag coverage (Phase 9)
Add --tool diff|read|bash|grep (single tool-card snapshot) and --frame N
(spinner frame); fix the --variant mapping to a direct theme index (0 ZERO …)
to match the flag help after the default change. Tests cover every snapshot
state (home/chat/json/sessions/perm/stream/tool).
2026-06-09 18:20:15 +05:30
Gnanam 9b1d02510c zeroline: sessions drawer slide-over (Phase 8)
ctrl+s opens a right-side sessions panel (Esc/ctrl+s closes, ↑/↓ navigate); it
dims the body and composites a bordered panel (header + session rows: accent id,
faint timestamp, title, model · turns; selected row gets an accent ▌ rail) on the
right, staying exactly h rows / w cells. Backed by the real sessions.Store
(store.List, most-recent first) with the sample list as a fallback. Snapshot
gains --sessions. Drawer is a top-level modal in Update (intercepts keys).
2026-06-09 18:17:15 +05:30
Gnanam d69396323d zeroline: TEXT/JSON view toggle (Phase 7)
ctrl+j toggles a jsonMode (model + ChatData.JSONMode), reflected by the title-bar
segmented toggle. In JSON mode RenderChat renders the run as a synthetic stream of
syntax-colored JSON events (.jsonwrap): a faint '$ zero run -p … --format json'
header + one event line per row (keys blue, strings ink, numbers amber, type
accent, punctuation faint), frame-exact. Snapshot gains --json. Pure view toggle;
the agent is unaffected.
2026-06-09 18:08:39 +05:30
Gnanam 30a9a060a5 zeroline: clear textinput prompt (composer owns ❯) + spec placeholder
The composer renders the ❯ prompt itself, so the textinput's own '❯ ' prompt
would double it in the live TUI; clear it for the zeroline skin and set the
spec placeholder 'describe a task for zero…'.
2026-06-09 18:03:59 +05:30
Gnanam 65139c7b04 zeroline: composer with accent prompt + run/stop affordance (Phase 6)
cmdRegion now renders an accent ❯ prompt + input with a right-aligned affordance:
lime 'run ↵' normally, red '■ stop' while the agent is working. Perm-mode key
hint updated to a/A/d. Snapshot chat input de-doubled (composer owns the prompt).
2026-06-09 18:03:26 +05:30
Gnanam 8c7c7e75ba zeroline: default the --snapshot theme to ZERO (variant 0) 2026-06-09 17:58:54 +05:30
Gnanam 1ded5ec253 zeroline: fix Phase 4.4 adversarial-review findings
- High: running-tool detection tested the dead 'toolcall' kind (rows are now
  'tool'), so a mid-run tool card stacked a spurious 'thinking…' line under it —
  now checks Kind=='tool' && Running.
- Medium: clip the tool-card head so a long (MCP) tool name/target can't wrap the
  head into extra rows.
- diffBody +N/-M stat now skips unified-diff ---/+++/@@ header lines; removed a
  dead sign variable.
- regression tests: running-tool suppresses thinking, long-name no head overflow,
  diff stat excludes headers.
Deferred (noted): real file line numbers, diff-head path/NEW tag, real bash exit
code (needs tools plumbing), Windows drive-letter grep paths, empty-id edge.
2026-06-09 17:57:26 +05:30
Gnanam f7f2c0c64a zeroline: permission card polish + clickable buttons (Phase 5)
Card: PERMISSION badge (amber fill) + risk on the top row, amber border, lime
allow button, keys a/A/d (capital A = always). Mouse: enable WithMouseCellMotion
in run.go + a tea.MouseMsg handler that hit-tests PermLayout and resolves the
permission on a left-click. Also fix grepBody to detab before clipping (a tab
counted as 1 but rendered wider, overflowing the card) — caught by the review.
Tests: PERMISSION render, mouse-click resolves allow, grep-tab no-overflow.
2026-06-09 17:54:23 +05:30
Gnanam 337bdd1a16 tools: edit_file emits a unified diff as its result (Phase 4.2)
edit_file's Output is now the focused old→new UnifiedDiff (the success summary
moves to Display.Summary), so the zeroline diff card — and the default skin —
show the actual change. apply_patch already carries a diff.
2026-06-09 17:44:32 +05:30
Gnanam 20a48e6af5 zeroline: bordered tool cards for diff/read/bash/grep (Phase 4.4)
Merge each tool call + its result into a single 'tool' card row (zerolineRows);
render a rounded ╭╮╰╯ card: head (accent icon + bold name + muted target +
spinner→✓/✗; accent border running, red border on error) + a per-tool body —
diff (+N/-M stat, numbered signed colored lines), read (numbered), bash ($ cmd +
output + exit foot), grep (path:line blue + count). Snapshot --page chat updated
to show the cards. (edit_file→UnifiedDiff result wiring is the next step.)
2026-06-09 17:41:38 +05:30
Gnanam 64f78875a9 zeroline: fix Phase 3 adversarial-review findings
- renderSay tone --muted (s.dim, was s.mute=faint); streaming + say now preserve
  line structure and re-clip long unbroken tokens (new wrapBlock helper).
- doneLine: spec glyph ■ (was ●), faint meta (s.mute), clipped to frame width
  (was a hardcoded 84 → spurious ellipsis on wide / backstop-only on narrow).
- system/deny notes render one styled line per input line (was firstLine-only,
  which silently truncated multi-line resume-session summaries); sys tone faint.
- regression tests: multi-line note, streaming newlines, long-token re-clip.
2026-06-09 17:31:07 +05:30
Gnanam 75342a4a31 tools: add UnifiedDiff + DiffStat (LCS line diff) for the zeroline diff card (Phase 4 pt 1)
Pure, dep-free LCS line diff emitting +/-/space-prefixed lines the zeroline
renderer parses; DiffStat counts +N/-M for the diff-card head. edit_file/
apply_patch wiring + the diff/read/bash/grep tool cards follow.
2026-06-09 17:25:18 +05:30
Gnanam eee8036c52 zeroline: redesign transcript blocks to the ZERO spec (Phase 3)
user → accent ❯ + ink (drops the 'you' label); assistant prose keeps markdown;
new final (accent rail + ink), done (●/red + faint meta), and sys/deny note
blocks; streaming renders muted say text + an accent ▌ caret (drops the ✦ zero
label and the bg-space caret).
2026-06-09 17:21:00 +05:30
Gnanam 0e1a5dde02 zeroline: redesign home/empty state to the ZERO spec (Phase 2)
ZERO wordmark (accent) + spec tagline + 'running zero against <model>' hint +
suggestion chips (accent ❯ + label, selected chip carries an accent ▌ rail).
Adds HomeData.Chips/ChipIndex + DefaultChips(); drops the recent-sessions list
and header stripe from home (sessions move to the drawer in Phase 8).
2026-06-09 17:15:28 +05:30
Gnanam aa3b610109 zeroline: redesign title + status bars to the ZERO spec (Phase 1 chrome)
Title bar: lime 0 brand mark + zero name + model menu label + cwd (hidden when
tight); TEXT|JSON segmented toggle + git branch on the right. Status bar: accent
dot + run mode, tok/s, tokens, ctx gauge, cost. Each bar stays exactly one row
and full width (bar() spacer). Run mode moves from the title to the status bar.
2026-06-09 17:05:50 +05:30
Gnanam 318e6937d5 zeroline: add ZERO design palette as the default theme (exact spec hex) 2026-06-09 16:56:16 +05:30
26 changed files with 1865 additions and 893 deletions

No files matched your search

+51 -18
View File
@@ -21,11 +21,14 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
fs.SetOutput(stderr)
snapshot := fs.Bool("snapshot", false, "render a single frame to stdout and exit (no TTY)")
page := fs.String("page", "home", "snapshot page: home|chat")
variant := fs.Int("variant", 1, "color theme 1-5 (1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
variant := fs.Int("variant", 0, "color theme 0-5 (0 ZERO, 1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
light := fs.Bool("light", false, "use the light variant for the snapshot")
perm := fs.Bool("perm", false, "show the centered permission modal in the chat snapshot")
boot := fs.Int("boot", -1, "render the boot splash at the given animation frame")
stream := fs.Bool("stream", false, "show a streaming assistant response in the chat snapshot")
jsonMode := fs.Bool("json", false, "render the chat snapshot in JSON mode (TEXT/JSON toggle)")
sessionsDrawer := fs.Bool("sessions", false, "show the sessions drawer in the chat snapshot")
tool := fs.String("tool", "", "show a single tool card in the chat snapshot: diff|read|bash|grep")
frameN := fs.Int("frame", 0, "animation frame for the chat snapshot (spinner)")
width := fs.Int("width", 100, "snapshot width")
height := fs.Int("height", 30, "snapshot height")
skipUnsafe := fs.Bool("skip-permissions-unsafe", false, "launch in unsafe permission mode (enables the ! shell escape)")
@@ -34,16 +37,10 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
if *snapshot {
v := *variant - 1
v := *variant // theme index directly (0 ZERO, 1 Phosphor, …)
if v < 0 || v >= len(zeroline.Themes) {
v = 0
}
if *boot >= 0 {
if _, err := fmt.Fprintln(stdout, zeroline.RenderBoot(v, !*light, *boot, *width, *height)); err != nil {
return 1
}
return 0
}
hdr := zeroline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
var frame string
if *page == "chat" {
@@ -51,19 +48,29 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Rows: []zeroline.Row{
{Kind: "user", Text: "refactor internal/agent/loop.go to extract tool execution"},
{Kind: "toolcall", Tool: "list_directory", Detail: "internal/agent"},
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of internal/agent:\n\nloop.go\ntypes.go\nloop_test.go"},
{Kind: "toolcall", Tool: "read_file", Detail: "internal/agent/loop.go"},
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: internal/agent/loop.go (164 lines)\n\n118 | func (l *Loop) run(ctx context.Context) error {"},
{Kind: "toolcall", Tool: "edit_file", Detail: "exec.go (new) · loop.go"},
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "--- a/internal/agent/loop.go\n+++ b/internal/agent/exec.go\n@@ -141,6 +141,3 @@\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "tool", Tool: "grep", Text: "internal/agent", Status: "ok", Detail: "internal/agent/loop.go:88:\tfor i, call := range reply.ToolCalls {\ninternal/agent/loop.go:90:\t\tgo func() {"},
{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok", Detail: "func (l *Loop) run(ctx context.Context) error {\n\tfor step := 0; step < l.MaxSteps; step++ {\n\t\treply, _, err := l.model.Complete(ctx, msgs, l.tools)"},
{Kind: "tool", Tool: "edit_file", Text: "internal/agent/exec.go", Status: "ok", Detail: " func (l *Loop) run(ctx context.Context) error {\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "tool", Tool: "bash", Text: "go test ./...", Status: "ok", Detail: "ok github.com/zero-dev/zero/internal/agent\t0.061s\nok github.com/zero-dev/zero/internal/tools\t0.140s"},
{Kind: "assistant", Text: "Done. Extracted a `ToolExecutor`:\n\n```go\nfunc (e *ToolExecutor) Dispatch(c Call) (Out, error) {\n\treturn e.route(c)\n}\n```\n\nThe switch in loop.go now delegates to one call. Tests pass."},
{Kind: "final", Text: "Extracted tool dispatch into a ToolExecutor; loop.go now delegates. go test ./... is green."},
{Kind: "done", Text: "4 tools · 1,284 tok · $0.04", Status: "ok"},
},
Input: "❯ ",
Input: "add a test for the new ToolExecutor",
Spin: *frameN,
}
if *tool != "" {
cd.Rows = toolSnapshotRows(*tool)
}
if *perm {
cd.Perm = &zeroline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
}
if *jsonMode {
cd.JSONMode = true
}
if *sessionsDrawer {
cd.Drawer = &zeroline.Drawer{Sessions: zeroline.DefaultSessions()}
}
if *stream {
cd.Rows = cd.Rows[:len(cd.Rows)-1] // drop the final assistant row
cd.Working = true
@@ -72,9 +79,11 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
frame = zeroline.RenderChat(cd)
} else {
frame = zeroline.RenderHome(zeroline.HomeData{
frame = zeroline.RenderChat(zeroline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Input: "❯ message zero — / commands · @ files · ! bash",
Input: "describe a task for zero…",
Chips: zeroline.DefaultChips(),
ChipIndex: 0,
})
}
if _, err := fmt.Fprintln(stdout, frame); err != nil {
@@ -89,3 +98,27 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
return runInteractiveTUIWithSkin(stderr, deps, "zeroline", permissionMode)
}
// toolSnapshotRows builds a chat focused on a single tool card type, for
// `zeroline --snapshot --tool <kind>`.
func toolSnapshotRows(kind string) []zeroline.Row {
user := zeroline.Row{Kind: "user", Text: "show me the " + kind + " tool card"}
var tool zeroline.Row
switch kind {
case "diff":
tool = zeroline.Row{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Status: "ok",
Detail: " \tfs.IntVar(&o.MaxSteps, \"max-steps\", 50, \"agent step cap\")\n+\tfs.BoolVar(&o.ShowVersion, \"version\", false, \"print version and exit\")\n \n+\tif o.ShowVersion {\n+\t\tfmt.Fprintln(o.Stdout, \"zero \"+buildinfo.Version)\n+\t}"}
case "read":
tool = zeroline.Row{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok",
Detail: "File: internal/agent/loop.go (164 lines)\n\n 1 | func (l *Loop) Run(ctx context.Context, task string) error {\n 2 | \tl.emit(Event{Type: EventStart})\n 3 | \tmsgs := l.seed(task)\n 4 | \tfor step := 0; step < l.MaxSteps; step++ {\n 5 | \t\treply, usage, err := l.model.Complete(ctx, msgs, l.tools)"}
case "bash":
tool = zeroline.Row{Kind: "tool", Tool: "bash", Text: "go test ./internal/cli/...", Status: "ok",
Detail: "ok \tgithub.com/zero-dev/zero/internal/cli\t0.214s"}
case "grep":
tool = zeroline.Row{Kind: "tool", Tool: "grep", Text: "internal/cli", Status: "ok",
Detail: "internal/cli/root.go:41:fs := flag.NewFlagSet(\"zero\", flag.ContinueOnError)\ninternal/cli/root.go:47:fs.StringVar(&o.Model, \"model\", env(\"ZERO_MODEL\"), \"model id\")"}
default:
tool = zeroline.Row{Kind: "tool", Tool: kind, Text: "(unknown tool kind: " + kind + ")", Status: "error", Detail: "supported: diff|read|bash|grep"}
}
return []zeroline.Row{user, tool, {Kind: "done", Text: "1 tool · 214 tok", Status: "ok"}}
}
+48
View File
@@ -0,0 +1,48 @@
package cli
import (
"bytes"
"strings"
"testing"
)
func TestZerolineSnapshotToolFlag(t *testing.T) {
cases := map[string][]string{
"diff": {"edit_file", "ShowVersion"},
"read": {"read_file", "Loop"},
"bash": {"bash", "exit 0"},
"grep": {"grep", "matches"},
}
for kind, wants := range cases {
var out, errb bytes.Buffer
code := runZeroline([]string{"--snapshot", "--page", "chat", "--tool", kind, "--width", "100", "--height", "20"}, &out, &errb, appDeps{})
if code != 0 {
t.Fatalf("--tool %s: exit %d, stderr=%s", kind, code, errb.String())
}
for _, w := range wants {
if !strings.Contains(out.String(), w) {
t.Errorf("--tool %s snapshot missing %q", kind, w)
}
}
}
}
func TestZerolineSnapshotModes(t *testing.T) {
for _, args := range [][]string{
{"--snapshot", "--page", "home"},
{"--snapshot", "--page", "chat"},
{"--snapshot", "--page", "chat", "--json"},
{"--snapshot", "--page", "chat", "--sessions"},
{"--snapshot", "--page", "chat", "--perm"},
{"--snapshot", "--page", "chat", "--stream", "--frame", "3"},
} {
var out, errb bytes.Buffer
full := append(args, "--width", "100", "--height", "24")
if code := runZeroline(full, &out, &errb, appDeps{}); code != 0 {
t.Fatalf("%v: exit %d, stderr=%s", args, code, errb.String())
}
if strings.TrimSpace(out.String()) == "" {
t.Errorf("%v produced empty output", args)
}
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ func (tool editFileTool) Run(_ context.Context, args map[string]any) Result {
suffix = "s"
}
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
result := okResult(summary)
// Emit the focused change as a unified diff so renderers can show a diff view;
// the diff is also clearer confirmation to the model than a prose summary.
result := okResult(UnifiedDiff(relativePath, oldString, newString))
result.ChangedFiles = []string{relativePath}
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff"}
result.Display = Display{Summary: summary, Kind: "diff"}
return result
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import "strings"
// UnifiedDiff returns a line-oriented diff between before and after. Each output
// line is prefixed with '+' (added), '-' (removed), or ' ' (unchanged context),
// computed from the longest common subsequence of lines. A created file
// (before == "") yields all-'+' lines. path is accepted for callers that want to
// associate the diff with a file; the body is what the zeroline renderer parses.
func UnifiedDiff(path, before, after string) string {
a := diffLines(before)
b := diffLines(after)
m, n := len(a), len(b)
// lcs[i][j] = length of the LCS of a[i:] and b[j:].
lcs := make([][]int, m+1)
for i := range lcs {
lcs[i] = make([]int, n+1)
}
for i := m - 1; i >= 0; i-- {
for j := n - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []string
i, j := 0, 0
for i < m && j < n {
switch {
case a[i] == b[j]:
out = append(out, " "+a[i])
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, "-"+a[i])
i++
default:
out = append(out, "+"+b[j])
j++
}
}
for ; i < m; i++ {
out = append(out, "-"+a[i])
}
for ; j < n; j++ {
out = append(out, "+"+b[j])
}
return strings.Join(out, "\n")
}
// DiffStat counts the added and removed lines in a UnifiedDiff body.
func DiffStat(diff string) (add, del int) {
if diff == "" {
return 0, 0
}
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+"):
add++
case strings.HasPrefix(ln, "-"):
del++
}
}
return add, del
}
// diffLines splits content into lines, dropping a single trailing newline so a
// file that ends in "\n" doesn't produce a spurious trailing empty line.
func diffLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
+54
View File
@@ -0,0 +1,54 @@
package tools
import (
"strings"
"testing"
)
func TestUnifiedDiffNewFile(t *testing.T) {
got := UnifiedDiff("hello.txt", "", "hi\nthere")
want := "+hi\n+there"
if got != want {
t.Fatalf("new-file diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffEdit(t *testing.T) {
before := "line1\nline2\nline3"
after := "line1\nline2-changed\nline3"
got := UnifiedDiff("f", before, after)
for _, want := range []string{" line1", "-line2", "+line2-changed", " line3"} {
if !strings.Contains(got, want) {
t.Fatalf("edit diff missing %q in:\n%s", want, got)
}
}
}
func TestUnifiedDiffInsertion(t *testing.T) {
got := UnifiedDiff("f", "a\nb", "a\nx\nb")
want := " a\n+x\n b"
if got != want {
t.Fatalf("insertion diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffDeletion(t *testing.T) {
got := UnifiedDiff("f", "a\nb\nc", "a\nc")
want := " a\n-b\n c"
if got != want {
t.Fatalf("deletion diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffEmpty(t *testing.T) {
if got := UnifiedDiff("f", "", ""); got != "" {
t.Fatalf("empty diff = %q, want empty", got)
}
}
func TestDiffStat(t *testing.T) {
add, del := DiffStat(" a\n+x\n+y\n-b\n c")
if add != 2 || del != 1 {
t.Fatalf("DiffStat = (+%d -%d), want (+2 -1)", add, del)
}
}
+1 -2
View File
@@ -218,7 +218,6 @@ func TestSuggestionOverlayRendersInDefaultSkin(t *testing.T) {
func TestSuggestionOverlayRendersInZerolineSkin(t *testing.T) {
m := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true})
m.width, m.height = 100, 30
m.booted = true
m.showSplash = false
m = typeRunes(t, m, "/mo")
@@ -241,7 +240,7 @@ func TestFileSuggestionsMatchesAndSkipsVCSDirs(t *testing.T) {
}
}
mustWrite("cmd/server/main.go")
mustWrite(".git/config") // hidden VCS dir: must be skipped
mustWrite(".git/config") // hidden VCS dir: must be skipped
mustWrite("node_modules/dep/index.js") // dependency dir: must be skipped
got := suggestionTokens(fileSuggestions(root, "main"))
+4 -5
View File
@@ -16,10 +16,10 @@ func TestModelSupportsVisionTUI(t *testing.T) {
modelName string
want bool
}{
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
{modelName: "", want: false},
}
for _, tc := range cases {
@@ -152,7 +152,6 @@ func TestZerolineViewShowsImageChips(t *testing.T) {
m.width = 100
m.height = 30
m.showSplash = false
m.booted = true
m.pendingImageLabels = []string{"photo.png"}
view := m.zerolineView()
+51 -7
View File
@@ -19,6 +19,7 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroline"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -79,7 +80,10 @@ type model struct {
themeVariant int // zeroline color theme (0-4)
themeDark bool // zeroline light/dark
frame int // animation frame counter (zeroline spinner)
booted bool // zeroline boot splash finished
jsonMode bool // zeroline TEXT/JSON view toggle (ctrl+j)
drawerOpen bool // zeroline sessions drawer (ctrl+s)
drawerIndex int // selected session row in the drawer
drawerSessions []zeroline.Session
streamingText string // live assistant text for the current segment
streamStartFrame int // frame the current stream segment began (tok/s)
@@ -225,8 +229,8 @@ func newModel(ctx context.Context, options Options) model {
input.Prompt = "zero > "
input.Placeholder = "Ask Zero to inspect, edit, explain, or run a command..."
if options.Skin == "zeroline" {
input.Prompt = "❯ "
input.Placeholder = "message zero — / commands · @ files · ! bash"
input.Prompt = "" // the zeroline composer renders its own ❯ prompt
input.Placeholder = "describe a task for zero…"
}
input.Focus()
@@ -275,6 +279,24 @@ func (m model) Init() tea.Cmd {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// The sessions drawer is a top-level modal: it intercepts close/navigate
// keys and swallows the rest. Ctrl+C (quit/cancel) and a pending permission
// always take precedence, so the drawer never traps the user.
if m.skin == "zeroline" && m.drawerOpen && m.pendingPermission == nil && msg.Type != tea.KeyCtrlC {
switch msg.String() {
case "esc", "ctrl+s", "q":
m.drawerOpen = false
case "up", "k":
if m.drawerIndex > 0 {
m.drawerIndex--
}
case "down", "j":
if m.drawerIndex < len(m.drawerSessions)-1 {
m.drawerIndex++
}
}
return m, nil
}
switch msg.Type {
case tea.KeyCtrlC:
// cancelRun records the in-flight run into flushRunIDs and writes the
@@ -396,7 +418,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.skin == "zeroline" {
m.booted = true // any key dismisses the boot splash
if nm, handled := m.handleZerolineKeys(msg); handled {
return nm, nil
}
@@ -407,6 +428,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input, cmd = m.input.Update(msg)
m.recomputeSuggestions()
return m, cmd
case tea.MouseMsg:
// Left-click resolves the zeroline permission card by hit-testing its
// buttons; the geometry is computed the same way the renderer lays them out.
if m.skin == "zeroline" && m.pendingPermission != nil &&
msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
if geo := zeroline.PermLayout(m.width, m.height); geo.Active {
switch geo.Hit(msg.X, msg.Y) {
case "allow":
return m.resolvePermission(permissionDecisionAllow)
case "always":
return m.resolvePermission(permissionDecisionAlwaysAllow)
case "deny":
return m.resolvePermission(permissionDecisionDeny)
}
}
}
return m, nil
case tea.FocusMsg:
if m.notifier != nil {
m.notifier.SetFocused(true)
@@ -422,9 +460,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.frame++
if m.frame >= bootFrames {
m.booted = true
}
return m, zerolineTick()
case agentTextMsg:
if msg.runID != m.activeRunID {
@@ -439,6 +474,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Bound the input width for the zeroline composer so a long line scrolls
// horizontally instead of growing past the frame and pushing the run/stop
// button off the right edge. Leave room for the ❯ prompt + the button.
if m.skin == "zeroline" && m.width > 16 {
m.input.Width = m.width - 12
}
return m, nil
case permissionRequestMsg:
if msg.runID != m.activeRunID {
@@ -634,6 +675,9 @@ func startsTurn(kind rowKind) bool {
}
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "A" { // capital A = always (spec: [a] allow, [A] always, [d] deny)
return m.resolvePermission(permissionDecisionAlwaysAllow)
}
switch strings.ToLower(msg.String()) {
case "a":
return m.resolvePermission(permissionDecisionAllow)
-1
View File
@@ -187,7 +187,6 @@ func TestPickerRendersInBothSkins(t *testing.T) {
// Zeroline skin.
z := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true, ModelName: "claude-sonnet-4.5"})
z.width, z.height = 100, 30
z.booted = true
z.showSplash = false
z.input.SetValue("/model")
updatedZ, _ := z.Update(tea.KeyMsg{Type: tea.KeyEnter})
+5 -5
View File
@@ -31,11 +31,11 @@ func Run(ctx context.Context, options Options) int {
if notify.Enabled(notify.Mode(strings.TrimSpace(options.Notify.Mode))) {
programOpts = append(programOpts, tea.WithReportFocus())
}
// NOTE: we intentionally do NOT enable mouse capture. Mouse cell-motion
// reporting routes clicks/drags to the program, which breaks the terminal's
// native text selection + copy and surprises users who expect normal
// click/select/copy/paste. The permission modal is fully keyboard-driven
// (a/y/d/Esc), so capturing the mouse buys little and costs core UX.
// Enable mouse cell-motion so the permission card's buttons are clickable.
// Tradeoff: this routes clicks/drags to the program and can interfere with the
// terminal's native text selection (most terminals let users hold Shift/Option
// to force native selection). The card stays fully keyboard-driven (a/A/d/Esc).
programOpts = append(programOpts, tea.WithMouseCellMotion())
program = tea.NewProgram(newModel(ctx, options), programOpts...)
if _, err := program.Run(); err != nil {
+104 -33
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"time"
@@ -13,10 +14,6 @@ import (
// agent events so "working…" spins smoothly between row updates.
type zerolineTickMsg time.Time
// bootFrames is how many ~120ms ticks the boot splash plays before the home
// page reveals (~1.9s), matching the mockup's splash timing.
const bootFrames = 16
func zerolineTick() tea.Cmd {
return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { return zerolineTickMsg(t) })
}
@@ -32,6 +29,17 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
case "ctrl+t":
m.themeVariant = (m.themeVariant + 1) % len(zeroline.Themes)
return m, true
case "ctrl+j":
m.jsonMode = !m.jsonMode // TEXT/JSON view toggle
return m, true
case "ctrl+s":
if m.showSplash {
return m, true // the drawer only renders on the chat page; ignore on home
}
m.drawerOpen = true // sessions drawer (Esc/ctrl+s closes; handled in Update)
m.drawerSessions = m.loadDrawerSessions()
m.drawerIndex = 0
return m, true
}
if strings.TrimSpace(m.input.Value()) == "" {
if k := msg.String(); len(k) == 1 && k >= "1" && k <= "5" {
@@ -42,6 +50,72 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
return m, false
}
// drawerData returns the open sessions drawer (nil when closed).
func (m model) drawerData() *zeroline.Drawer {
if !m.drawerOpen {
return nil
}
return &zeroline.Drawer{Sessions: m.drawerSessions, Selected: m.drawerIndex}
}
// loadDrawerSessions lists real sessions from the store (most-recent first),
// falling back to the sample list when the store is empty or unavailable.
func (m model) loadDrawerSessions() []zeroline.Session {
if m.sessionStore == nil {
return zeroline.DefaultSessions()
}
items, err := m.sessionStore.List()
if err != nil || len(items) == 0 {
return zeroline.DefaultSessions()
}
out := make([]zeroline.Session, 0, len(items))
for _, it := range items {
title := strings.TrimSpace(it.Title)
if title == "" {
title = "(untitled)"
}
id := it.SessionID
if len(id) > 6 {
id = id[:6]
}
out = append(out, zeroline.Session{
ID: id,
When: m.relativeWhen(it.UpdatedAt),
Title: title,
Model: it.ModelID,
Turns: it.EventCount,
})
if len(out) >= 12 {
break
}
}
return out
}
// relativeWhen formats an RFC3339 timestamp as a short relative age (falls back
// to the raw string if it can't be parsed).
func (m model) relativeWhen(s string) string {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
return s
}
now := time.Now
if m.now != nil {
now = m.now
}
d := now().Sub(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
// zerolineHeader builds the zeroline header, including the live cost and
// cumulative token total from the usage tracker (with the unpriced fallback) so
// the top/bottom bars actually reflect consumption instead of always showing $0.
@@ -72,32 +146,20 @@ func (m model) zerolineView() string {
height = 30
}
// Boot splash reveals on launch, then the home page.
if !m.booted && m.showSplash {
return zeroline.RenderBoot(m.themeVariant, m.themeDark, m.frame, width, height)
// Spec composer placeholder per surface (m is a value receiver, so this only
// affects this render). The composer renders the ❯ prompt + accent caret.
if m.showSplash {
m.input.Placeholder = "describe a task for zero…"
} else {
m.input.Placeholder = "message zero…"
}
header := m.zerolineHeader()
// Home until the first turn is submitted.
if m.showSplash {
return zeroline.RenderHome(zeroline.HomeData{
Variant: m.themeVariant,
Dark: m.themeDark,
Width: width,
Height: height,
Header: header,
Input: m.input.View(),
Suggestions: m.zerolineSuggestions(),
SelectedIdx: m.suggestionIdx,
Picker: m.zerolinePicker(),
})
}
rows := m.zerolineRows()
running := false
for _, r := range rows {
if r.Kind == "toolcall" && r.Running {
if r.Kind == "tool" && r.Running {
running = true
}
}
@@ -119,6 +181,10 @@ func (m model) zerolineView() string {
Stream: m.streamingText,
TokS: m.streamTokS(),
Spin: m.frame,
JSONMode: m.jsonMode,
Drawer: m.drawerData(),
Chips: zeroline.DefaultChips(), // shown only on the empty state (no rows)
ChipIndex: -1,
Perm: m.zerolinePerm(),
AskUser: askUser,
Input: m.input.View(),
@@ -204,11 +270,12 @@ func (m model) streamTokS() int {
}
func (m model) zerolineRows() []zeroline.Row {
// a tool call is "running" until a result row with the same id arrives
resultIDs := make(map[string]bool)
// Merge each tool call with its matching result (by id) into a single "tool"
// card row: running until the result arrives, then carrying its status + body.
results := make(map[string]transcriptRow)
for _, r := range m.transcript {
if r.kind == rowToolResult && r.id != "" {
resultIDs[r.id] = true
results[r.id] = r
}
}
rows := make([]zeroline.Row, 0, len(m.transcript))
@@ -219,14 +286,18 @@ func (m model) zerolineRows() []zeroline.Row {
case rowAssistant:
rows = append(rows, zeroline.Row{Kind: "assistant", Text: r.text})
case rowToolCall:
rows = append(rows, zeroline.Row{
Kind: "toolcall",
Tool: r.tool,
Detail: r.detail,
Running: !(r.id != "" && resultIDs[r.id]),
})
res, done := results[r.id]
row := zeroline.Row{Kind: "tool", Tool: r.tool, Text: r.detail, Running: !(r.id != "" && done)}
if done {
row.Status = string(res.status)
row.Detail = res.detail
}
rows = append(rows, row)
case rowToolResult:
rows = append(rows, zeroline.Row{Kind: "toolresult", Tool: r.tool, Status: string(r.status), Detail: r.detail})
if r.id != "" {
continue // already merged into its tool call above
}
rows = append(rows, zeroline.Row{Kind: "tool", Tool: r.tool, Status: string(r.status), Detail: r.detail})
case rowPermission:
rows = append(rows, zeroline.Row{Kind: "permission", Text: r.text})
case rowAskUser:
+106 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroline"
)
func newZerolineModel() model {
@@ -20,7 +21,6 @@ func newZerolineModel() model {
ModelName: "claude-sonnet-4.5",
})
m.width, m.height = 100, 30
m.booted = true // skip the boot splash animation; these tests cover home/chat
return m
}
@@ -28,7 +28,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
m := newZerolineModel()
// Home is shown until the first turn (showSplash true).
if !strings.Contains(m.View(), "Own your agent") {
if !strings.Contains(m.View(), "std-lib-first") {
t.Fatal("expected Zen home before first turn")
}
@@ -41,7 +41,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
{kind: rowToolResult, id: "t1", tool: "grep", status: tools.StatusOK, detail: "3 matches"},
}
chat := m.View()
for _, want := range []string{"WORKING", "you", "grep", "claude-sonnet-4.5", "thinking"} {
for _, want := range []string{"WORKING", "❯", "grep", "claude-sonnet-4.5", "thinking"} {
if !strings.Contains(chat, want) {
t.Errorf("chat view missing %q", want)
}
@@ -91,13 +91,115 @@ func TestZerolinePermissionRender(t *testing.T) {
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
}
out := m.View()
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "deny"} {
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "deny"} {
if !strings.Contains(out, want) {
t.Errorf("permission view missing %q", want)
}
}
}
func TestZerolinePermissionMouseClick(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
m.pending = true
var got []permissionDecision
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
decide: func(d agent.PermissionDecision) { got = append(got, permissionDecision(d.Action)) },
}
geo := zeroline.PermLayout(m.width, m.height)
if !geo.Active {
t.Fatal("expected active permission geometry at 100x30")
}
click := tea.MouseMsg{X: geo.Allow.X + geo.Allow.W/2, Y: geo.Allow.Y, Action: tea.MouseActionRelease, Button: tea.MouseButtonLeft}
updated, _ := m.Update(click)
next := updated.(model)
if len(got) != 1 || got[0] != permissionDecisionAllow {
t.Fatalf("mouse click on allow should resolve allow, got %#v", got)
}
if next.pendingPermission != nil {
t.Fatal("permission should clear after a resolving click")
}
}
func TestZerolineRunningToolSuppressesThinking(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
m.pending = true
// A tool call with no matching result → a running card; the separate
// "thinking…" line must be suppressed (not stacked under the running card).
m.transcript = []transcriptRow{
{kind: rowToolCall, id: "t1", tool: "bash", text: "tool call: bash", detail: "go test ./..."},
}
if out := m.View(); strings.Contains(out, "thinking") {
t.Error("a running tool card should suppress the separate 'thinking' line")
}
}
func TestZerolineDrawerOpenClose(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
// ctrl+s opens the drawer and renders the panel
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
m2 := updated.(model)
if !m2.drawerOpen {
t.Fatal("ctrl+s should open the sessions drawer")
}
if !strings.Contains(m2.View(), "sessions") {
t.Error("open drawer should render the panel")
}
// esc closes it
updated2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEsc})
if updated2.(model).drawerOpen {
t.Fatal("esc should close the sessions drawer")
}
}
func TestZerolineDrawerCtrlCNotSwallowed(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
m2 := upd.(model)
if !m2.drawerOpen {
t.Fatal("ctrl+s should open the drawer")
}
if _, cmd := m2.Update(tea.KeyMsg{Type: tea.KeyCtrlC}); cmd == nil {
t.Error("Ctrl+C must not be swallowed by the drawer (it should quit)")
}
}
func TestZerolineDrawerNotOpenedOnHome(t *testing.T) {
m := newZerolineModel()
m.showSplash = true // home page
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
if upd.(model).drawerOpen {
t.Error("ctrl+s on the home page must not open the drawer (nothing renders it there)")
}
}
func TestZerolineComposerPlaceholderClean(t *testing.T) {
m := newZerolineModel()
m.input.SetValue("")
m.showSplash = true
home := m.View()
if strings.Contains(home, "%") {
t.Errorf("home composer must have no %% artifact: %q", home)
}
if !strings.Contains(home, "describe a task for zero") {
t.Error("home placeholder text missing")
}
m.showSplash = false
chat := m.View()
if strings.Contains(chat, "%") {
t.Errorf("chat composer must have no %% artifact: %q", chat)
}
if !strings.Contains(chat, "message zero") {
t.Error("chat placeholder text missing")
}
}
func TestZerolineThemeKeys(t *testing.T) {
m := newZerolineModel()
// digit selects theme when input empty
+62
View File
@@ -0,0 +1,62 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestTitleBarSegmentsAndWidth(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Cwd: "~/src/shop", Branch: "main", Dirty: true, Model: "claude-sonnet-4-5"}
out := s.topBar("normal", h, 110, false)
if lipgloss.Height(out) != 1 {
t.Fatalf("title bar must be one row, got %d", lipgloss.Height(out))
}
if lipgloss.Width(out) != 110 {
t.Fatalf("title bar width = %d, want 110 (full width)", lipgloss.Width(out))
}
plain := stripANSI(out)
for _, want := range []string{"zero", "model", "claude-sonnet-4-5", "~/src/shop", "TEXT", "JSON", "main"} {
if !strings.Contains(plain, want) {
t.Errorf("title bar missing %q in %q", want, plain)
}
}
}
func TestTitleBarHidesCwdWhenTight(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Cwd: "~/src/shop", Branch: "main", Model: "m"}
out := s.topBar("normal", h, 48, false)
if lipgloss.Width(out) != 48 {
t.Fatalf("tight title bar width = %d, want 48", lipgloss.Width(out))
}
if strings.Contains(stripANSI(out), "~/src/shop") {
t.Errorf("cwd must be hidden at tight width, got %q", stripANSI(out))
}
}
func TestStatusBarSegmentsAndMode(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{CtxPct: 42, Cost: 0.04, TotalTokens: 1284}
out := s.botBar("work", h, 0, 12, 110)
if lipgloss.Height(out) != 1 {
t.Fatalf("status bar must be one row, got %d", lipgloss.Height(out))
}
if lipgloss.Width(out) != 110 {
t.Fatalf("status bar width = %d, want 110 (full width)", lipgloss.Width(out))
}
plain := stripANSI(out)
for _, want := range []string{"WORKING", "tok/s", "tok ", "ctx", "$"} {
if !strings.Contains(plain, want) {
t.Errorf("status bar missing %q in %q", want, plain)
}
}
if got := stripANSI(s.botBar("done", h, 0, 0, 110)); !strings.Contains(got, "DONE") {
t.Errorf("status bar should show DONE for run=done, got %q", got)
}
if got := stripANSI(s.botBar("blocked", h, 0, 0, 110)); !strings.Contains(got, "BLOCKED") {
t.Errorf("status bar should show BLOCKED for run=blocked, got %q", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestDrawerRendersSessions(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "hello"}})
d.Width, d.Height = 110, 22
d.Drawer = &Drawer{Sessions: DefaultSessions(), Selected: 0}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 22 {
t.Fatalf("drawer frame height = %d, want 22 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 110 {
t.Fatalf("drawer line exceeds width 110: %d", lipgloss.Width(line))
}
}
plain := stripANSI(out)
for _, want := range []string{"sessions", "resume / fork / export", "0a91f3", "Add streaming SSE", "14 turns"} {
if !strings.Contains(plain, want) {
t.Errorf("drawer missing %q", want)
}
}
}
func TestDrawerSelectionRail(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
panel := strings.Join(s.drawerPanel(&Drawer{Sessions: DefaultSessions(), Selected: 1}, 40, 20), "\n")
// the selected (2nd) row carries the accent rail
if !strings.Contains(stripANSI(panel), "▌") {
t.Error("selected session row should show the accent rail")
}
}
+40
View File
@@ -0,0 +1,40 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
// The empty state renders INSIDE the chat chrome (title bar + status bar +
// composer), with the 0 mark + tagline + model hint + chips centered in the body.
func TestEmptyStateShowsChromeAndContent(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 90, Height: 30,
Header: Header{Model: "claude-sonnet-4-5", Cwd: "~/code/zero"},
Chips: []string{"Add a --version flag", "Why is go vet failing?", "Create hello.txt"},
}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 30 {
t.Fatalf("empty-state height = %d, want 30 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 90 {
t.Fatalf("line exceeds width 90: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
plain := stripANSI(out)
for _, want := range []string{
"zero", // title bar brand
"std-lib-first", "running", "against", "claude-sonnet-4-5", // empty-state hint
"Add a --version flag", "Why is go vet failing?", "Create hello.txt", // chips
"tok", // status bar present (chrome, not a bare Zen page)
} {
if !strings.Contains(plain, want) {
t.Errorf("empty state missing %q", want)
}
}
}
// Chip selection/border behavior is covered by TestChipBoxBorderedAndSelected.
+63
View File
@@ -0,0 +1,63 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestJSONModeRendersEvents(t *testing.T) {
d := chatWith([]Row{
{Kind: "user", Text: "add a --version flag"},
{Kind: "tool", Tool: "edit_file", Text: "cli/root.go", Status: "ok", Detail: "+x"},
{Kind: "final", Text: "Done."},
{Kind: "done", Text: "1 tool", Status: "ok"},
})
d.JSONMode = true
out := stripANSI(RenderChat(d))
for _, want := range []string{
`$ `, `zero run`, `--format json`,
`"type"`, `"user"`, `"tool_use"`, `"edit_file"`, `"result"`, `"done"`,
} {
if !strings.Contains(out, want) {
t.Errorf("JSON view missing %q", want)
}
}
// the transcript card border must NOT appear in JSON mode
if strings.Contains(out, "╭") {
t.Error("JSON mode should not render tool-card borders")
}
}
func TestJSONModeTitleToggle(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Model: "m"}
// In text mode the toggle highlights TEXT; in JSON mode it highlights JSON.
// Both labels are always present; assert the bar still renders one full row.
for _, jm := range []bool{false, true} {
out := s.topBar("normal", h, 100, jm)
if lipgloss.Height(out) != 1 || lipgloss.Width(out) != 100 {
t.Fatalf("title bar not 1x100 (jsonMode=%v): %dx%d", jm, lipgloss.Width(out), lipgloss.Height(out))
}
plain := stripANSI(out)
if !strings.Contains(plain, "TEXT") || !strings.Contains(plain, "JSON") {
t.Errorf("toggle missing TEXT/JSON (jsonMode=%v): %q", jm, plain)
}
}
}
func TestJSONModeFrameExact(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "ok"}})
d.JSONMode = true
d.Width, d.Height = 100, 20
out := RenderChat(d)
if h := lipgloss.Height(out); h != 20 {
t.Fatalf("JSON frame height = %d, want 20", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("JSON line exceeds width 100: %d", lipgloss.Width(line))
}
}
}
-169
View File
@@ -1,169 +0,0 @@
package zeroline
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
gstyles "github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
)
// mdKey identifies a cached glamour renderer. A renderer bakes in the theme
// colors AND the word-wrap width, so any change to variant, mode or width needs
// a fresh one.
type mdKey struct {
variant int
dark bool
width int
}
// mdOutKey caches the RENDERED output per message so glamour runs once per
// (message, theme, width) instead of every frame — RenderChat is called on every
// spinner tick and keystroke, so re-rendering all assistant markdown each time
// would make the TUI sluggish on a long transcript.
type mdOutKey struct {
text string
variant int
dark bool
width int
}
const mdOutCacheMax = 512 // bounded so a resize drag (many widths) can't grow it forever
const mdCacheMax = 256 // ditto for the renderer cache: a resize mints a width key per frame
var (
mdMu sync.Mutex
mdCache = map[mdKey]*glamour.TermRenderer{}
mdOutput = map[mdOutKey]string{}
)
// renderMarkdown renders CommonMark to styled terminal text using glamour, which
// also syntax-highlights fenced code blocks via chroma — markdown + code
// highlighting in a single pass. The output is themed to the active palette and
// sits on the canvas background so no "card" reappears against the full-bleed
// surface. Trailing blank lines glamour appends are trimmed so spacing stays
// tight with the surrounding transcript.
func renderMarkdown(text string, p Pal, variant int, dark bool, width int) string {
if width < 1 {
width = 1
}
key := mdOutKey{text, variant, dark, width}
mdMu.Lock()
if cached, ok := mdOutput[key]; ok {
mdMu.Unlock()
return cached
}
mdMu.Unlock()
// markdownRenderer takes mdMu itself, so it must be called WITHOUT the lock
// held (sync.Mutex is not reentrant).
r := markdownRenderer(p, variant, dark, width)
if r == nil {
return text
}
out, err := r.Render(text)
if err != nil {
return text
}
out = strings.Trim(out, "\n")
mdMu.Lock()
if len(mdOutput) >= mdOutCacheMax {
mdOutput = map[mdOutKey]string{} // simple bounded reset; cheap to repopulate
}
mdOutput[key] = out
mdMu.Unlock()
return out
}
// markdownRenderer returns a glamour renderer for the given palette + width,
// building and caching one on first use. Cache misses build a renderer keyed by
// variant/mode/width so width changes (resizes) get a fresh, correctly wrapped
// renderer.
func markdownRenderer(p Pal, variant int, dark bool, width int) *glamour.TermRenderer {
if width < 1 {
width = 1
}
key := mdKey{variant, dark, width}
mdMu.Lock()
defer mdMu.Unlock()
if r, ok := mdCache[key]; ok {
return r
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(themeStyleConfig(p, dark)),
glamour.WithWordWrap(width),
)
if err != nil {
return nil
}
if len(mdCache) >= mdCacheMax {
mdCache = map[mdKey]*glamour.TermRenderer{} // simple bounded reset; cheap to repopulate
}
mdCache[key] = r
return r
}
func sp(s string) *string { return &s }
// hex returns the lipgloss color as a "#rrggbb" string suitable for glamour /
// chroma color fields.
func hex(c lipgloss.Color) string { return string(c) }
// themeStyleConfig derives a glamour StyleConfig from the theme palette. It
// starts from glamour's stock dark/light style (so code-block chroma and list
// structure stay sensible) and overrides the load-bearing colors with the
// theme: headings -> Accent, links -> Accent2, inline code -> readable Fg on the
// panel bg, list bullets -> Mute, body text -> Fg on the theme Bg. The document
// background is set to the theme Bg to match the full-bleed canvas.
func themeStyleConfig(p Pal, dark bool) ansi.StyleConfig {
var cfg ansi.StyleConfig
if dark {
cfg = gstyles.DarkStyleConfig
} else {
cfg = gstyles.LightStyleConfig
}
bg := hex(p.Bg)
// Body text + full-bleed background, with no extra block margin so the
// transcript's own indent governs alignment.
cfg.Document.Color = sp(hex(p.Fg))
cfg.Document.BackgroundColor = sp(bg)
cfg.Document.BlockPrefix = ""
cfg.Document.BlockSuffix = ""
zero := uint(0)
cfg.Document.Margin = &zero
// Headings -> Accent, bold, on the theme bg (drop glamour's H1 color block).
cfg.Heading.Color = sp(hex(p.Accent))
cfg.Heading.BackgroundColor = sp(bg)
cfg.H1.Color = sp(hex(p.Accent))
cfg.H1.BackgroundColor = sp(bg)
cfg.H1.Bold = boolp(true)
// Links + link text -> Accent2.
cfg.Link.Color = sp(hex(p.Accent2))
cfg.LinkText.Color = sp(hex(p.Accent2))
// Inline code -> readable Fg on the panel background.
cfg.Code.Color = sp(hex(p.Fg))
cfg.Code.BackgroundColor = sp(hex(p.Panel))
// List bullets / enumeration -> Mute.
cfg.Item.Color = sp(hex(p.Mute))
cfg.Enumeration.Color = sp(hex(p.Mute))
// Block quotes + emphasis pick up theme dim/accent.
cfg.BlockQuote.Color = sp(hex(p.Mute))
cfg.Emph.Color = sp(hex(p.Dim))
cfg.Strong.Color = sp(hex(p.Fg))
return cfg
}
func boolp(b bool) *bool { return &b }
+9 -145
View File
@@ -3,150 +3,12 @@ package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
// forceColor makes lipgloss emit ANSI escapes regardless of the test TTY so we
// can assert that styled output is actually colored.
func forceColor(t *testing.T) {
t.Helper()
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
}
func TestRenderMarkdownStripsSyntax(t *testing.T) {
p := Resolve(0, true)
out := renderMarkdown("**bold** and `code`", p, 0, true, 60)
if strings.TrimSpace(out) == "" {
t.Fatal("renderMarkdown returned empty output")
}
if strings.Contains(out, "**") {
t.Errorf("markdown markers leaked into output: %q", stripANSI(out))
}
// the words themselves must survive the render
plain := stripANSI(out)
for _, w := range []string{"bold", "code"} {
if !strings.Contains(plain, w) {
t.Errorf("rendered markdown missing %q: %q", w, plain)
}
}
// no trailing blank lines (we Trim them so transcript spacing stays tight)
if strings.HasSuffix(out, "\n") || strings.HasPrefix(out, "\n") {
t.Errorf("renderMarkdown left surrounding blank lines: %q", out)
}
}
func TestRenderMarkdownHighlightsCodeFence(t *testing.T) {
p := Resolve(1, true)
src := "Here is code:\n\n```go\nfunc main() {}\n```\n"
out := renderMarkdown(src, p, 1, true, 70)
plain := stripANSI(out)
if !strings.Contains(plain, "func main()") {
t.Errorf("fenced code body missing from render: %q", plain)
}
if strings.Contains(plain, "```") {
t.Errorf("code fence markers leaked into output: %q", plain)
}
}
func TestMarkdownRendererCachedPerKey(t *testing.T) {
p := Resolve(2, true)
r1 := markdownRenderer(p, 2, true, 80)
r2 := markdownRenderer(p, 2, true, 80)
if r1 != r2 {
t.Error("expected same cached renderer for identical key")
}
r3 := markdownRenderer(p, 2, true, 81) // width change -> new renderer
if r1 == r3 {
t.Error("width change should produce a distinct renderer")
}
r4 := markdownRenderer(p, 3, true, 80) // variant change -> new renderer
if r1 == r4 {
t.Error("variant change should produce a distinct renderer")
}
}
func TestMarkdownRendererCacheBounded(t *testing.T) {
// A resize drag mints a fresh width key on every frame, so the renderer cache
// (the expensive goldmark+chroma objects) must be bounded just like the output
// cache — otherwise it grows without limit.
p := Resolve(0, true)
mdMu.Lock()
mdCache = map[mdKey]*glamour.TermRenderer{}
mdMu.Unlock()
for w := 1; w <= mdCacheMax*3; w++ {
markdownRenderer(p, 0, true, w)
}
mdMu.Lock()
n := len(mdCache)
mdMu.Unlock()
if n > mdCacheMax {
t.Errorf("mdCache grew past bound: %d entries, want <= %d", n, mdCacheMax)
}
}
func TestColorizeDiffColorsAddsAndDels(t *testing.T) {
forceColor(t)
p := Resolve(0, true)
diff := "@@ -1,2 +1,2 @@\n context line\n-removed line\n+added line\n"
out := colorizeDiff(diff, p)
// content survives
plain := stripANSI(out)
for _, w := range []string{"added line", "removed line", "context line"} {
if !strings.Contains(plain, w) {
t.Errorf("diff output missing %q: %q", w, plain)
}
}
// a subtle left gutter is present
if !strings.Contains(plain, "│") {
t.Errorf("diff output missing left gutter: %q", plain)
}
// adds and dels are colored DISTINCTLY: the addition is rendered in the
// theme Green and the deletion in the theme Red. The diff line keeps its
// leading +/- marker, so we assert against the marked text.
green := lipgloss.NewStyle().Foreground(p.Green).Render("+added line")
red := lipgloss.NewStyle().Foreground(p.Red).Render("-removed line")
if !strings.Contains(out, green) {
t.Errorf("added line not colored with theme Green")
}
if !strings.Contains(out, red) {
t.Errorf("removed line not colored with theme Red")
}
if string(p.Green) == string(p.Red) {
t.Fatal("theme 0 unexpectedly uses the same color for green/red")
}
}
func TestColorizeDiffCapsLongDiffs(t *testing.T) {
p := Resolve(0, true)
var b strings.Builder
b.WriteString("@@ -1,100 +1,100 @@\n")
for i := 0; i < 100; i++ {
b.WriteString("+line\n")
}
out := stripANSI(colorizeDiff(b.String(), p))
if !strings.Contains(out, "more lines") {
t.Errorf("long diff not capped with a footer: %q", out)
}
// the cap means far fewer than 101 rendered lines
if n := strings.Count(out, "\n") + 1; n > diffMaxLines+2 {
t.Errorf("diff not capped: %d lines rendered", n)
}
}
func TestLooksLikeDiff(t *testing.T) {
cases := map[string]bool{
"@@ -1 +1 @@\n-a\n+b": true,
"--- a\n+++ b\n-x\n+y": true,
"@@ -1 +1 @@\n-a\n+b": true,
"--- a\n+++ b\n-x\n+y": true,
"+just an addition": true,
"plain prose with no diff": false,
"wrote 12 lines to file.go": false,
@@ -158,7 +20,9 @@ func TestLooksLikeDiff(t *testing.T) {
}
}
func TestAssistantRowRendersMarkdown(t *testing.T) {
func TestAssistantRowRendersPlainMuted(t *testing.T) {
// .blk-say is plain MUTED prose (no markdown formatting, no panel): markers are
// preserved verbatim rather than rendered through glamour.
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 40,
Rows: []Row{
@@ -166,11 +30,11 @@ func TestAssistantRowRendersMarkdown(t *testing.T) {
},
}
out := stripANSI(RenderChat(d))
if strings.Contains(out, "**bold**") {
t.Errorf("completed assistant markdown not rendered (raw markers present): %q", out)
if !strings.Contains(out, "**bold**") {
t.Errorf("assistant prose should render plainly (keep markers): %q", out)
}
if !strings.Contains(out, "bold") {
t.Errorf("assistant text missing from render: %q", out)
if !strings.Contains(out, "- one") || !strings.Contains(out, "- two") {
t.Errorf("assistant list lines missing: %q", out)
}
}
+105
View File
@@ -0,0 +1,105 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
// FIX 1: .blk-say is plain MUTED prose with no panel/background; only .blk-final
// keeps the accent rail + ink. Glamour formatting would drop the backticks of
// inline code, so their survival proves the prose is rendered plainly.
func TestAssistantSayIsPlainMuted(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "use the `flag` package here"}})))
if !strings.Contains(out, "`flag`") {
t.Errorf("assistant prose must render plainly (keep backticks), got: %q", out)
}
final := stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All set."}})))
if !strings.Contains(final, "│ All set.") {
t.Errorf("final block must keep the accent rail + ink text, got: %q", final)
}
}
// FIX 2: each home suggestion is its own bordered rounded box; the selected one
// uses the accent (rendered as a thick border so it's visible without color).
func TestChipBoxBorderedAndSelected(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
un := stripANSI(s.chipBox("hello world", false, 50))
sel := stripANSI(s.chipBox("hello world", true, 50))
if !strings.Contains(un, "╭") || !strings.Contains(un, "╰") {
t.Errorf("unselected chip must be a rounded box, got: %q", un)
}
if !strings.Contains(un, "❯") || !strings.Contains(un, "hello world") {
t.Errorf("chip must carry the accent arrow + label, got: %q", un)
}
if lipgloss.Width(strings.Split(un, "\n")[0]) != 50 {
t.Errorf("chip box must be exactly w wide, got %d", lipgloss.Width(strings.Split(un, "\n")[0]))
}
if !strings.Contains(sel, "┏") {
t.Errorf("selected chip must use the accent (thick) border, got: %q", sel)
}
if strings.Contains(un, "┏") {
t.Errorf("unselected chip must be rounded, not thick, got: %q", un)
}
}
// The read_file tool already numbers its lines ("N | …") under a "File: … (N
// lines)" header, so the read card must NOT add a second number column or repeat
// the header.
func TestReadCardNoDoubleNumbering(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
detail := "File: x.py (132 lines)\n\n 1 | import random\n 2 | import time"
body := s.toolBody(Row{Kind: "tool", Tool: "read_file", Detail: detail}, 80)
joined := stripANSI(strings.Join(body, "\n"))
if strings.Contains(joined, "File:") {
t.Errorf("read card should drop the redundant File: header: %q", joined)
}
if len(body) == 0 || stripANSI(body[0]) != " 1 | import random" {
t.Errorf("read card should show the tool's own numbering once, got: %q", joined)
}
}
// Resolved-permission line is colored by outcome (.perm-resolved): allow/always
// green ✓, deny red ✗, pending amber ⚠.
func TestPermissionLineColoredByOutcome(t *testing.T) {
allow := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: write_file allow risk:medium"}})))
if !strings.Contains(allow, "✓") || strings.Contains(allow, "⚠") {
t.Errorf("allowed permission should show ✓ (not ⚠): %q", allow)
}
deny := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash deny risk:high"}})))
if !strings.Contains(deny, "✗") {
t.Errorf("denied permission should show ✗: %q", deny)
}
pend := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash prompt risk:high"}})))
if !strings.Contains(pend, "⚠") {
t.Errorf("pending permission should show ⚠: %q", pend)
}
}
func TestHomeChipsAreSeparateBorderedBoxes(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 34, Header: Header{Model: "m"},
Chips: []string{"alpha", "beta", "gamma"}, ChipIndex: 1,
}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 34 {
t.Fatalf("empty-state height = %d, want 34 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("home line exceeds width 100: %d", lipgloss.Width(line))
}
}
plain := stripANSI(out)
for _, c := range []string{"alpha", "beta", "gamma"} {
if !strings.Contains(plain, "❯ "+c) {
t.Errorf("chip %q missing arrow+label", c)
}
}
// selected chip (index 1) renders the accent thick border
if !strings.Contains(plain, "┏") {
t.Error("home should show the selected chip's accent border")
}
}
File diff suppressed because it is too large. Load diff
+22 -65
View File
@@ -8,8 +8,11 @@ import (
)
func TestThemesCount(t *testing.T) {
if len(Themes) != 5 {
t.Fatalf("expected 5 themes, got %d", len(Themes))
if len(Themes) != 6 {
t.Fatalf("expected 6 themes (ZERO + 5 legacy), got %d", len(Themes))
}
if Themes[0].Name != "ZERO" {
t.Fatalf("Themes[0] = %q, want ZERO (the default)", Themes[0].Name)
}
for i, th := range Themes {
if th.Name == "" || th.Dark.Bg == "" || th.Light.Bg == "" {
@@ -18,15 +21,15 @@ func TestThemesCount(t *testing.T) {
}
}
func TestRenderHomeAllThemes(t *testing.T) {
func TestEmptyStateAllThemes(t *testing.T) {
for v := 0; v < len(Themes); v++ {
out := RenderHome(HomeData{
out := RenderChat(ChatData{
Variant: v, Dark: true, Width: 100, Height: 28,
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
Input: "❯ message zero",
Chips: DefaultChips(),
})
if !strings.Contains(out, "Own your agent") {
t.Errorf("theme %d: home missing tagline", v)
if !strings.Contains(stripANSI(out), "std-lib-first") {
t.Errorf("theme %d: empty state missing tagline", v)
}
}
}
@@ -37,13 +40,12 @@ func TestRenderChatLiveData(t *testing.T) {
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
Rows: []Row{
{Kind: "user", Text: "refactor the loop"},
{Kind: "toolcall", Tool: "grep", Detail: "pattern: case"},
{Kind: "toolresult", Tool: "grep", Status: "ok", Detail: "3 matches"},
{Kind: "tool", Tool: "grep", Text: "pattern: case", Status: "ok", Detail: "internal/x.go:3:case"},
{Kind: "assistant", Text: "Here is the plan."},
},
}
out := RenderChat(d)
for _, want := range []string{"DONE", "you", "grep", "✦ zero", "claude-sonnet-4.5"} {
for _, want := range []string{"DONE", "❯", "grep", "claude-sonnet-4.5"} {
if !strings.Contains(out, want) {
t.Errorf("chat render missing %q", want)
}
@@ -67,7 +69,7 @@ func TestRenderChatLiveData(t *testing.T) {
d.Working = false
d.Perm = &Perm{Tool: "edit_file", Risk: "medium", Reason: "writes a file"}
p := RenderChat(d)
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "always", "deny"} {
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "always", "deny"} {
if !strings.Contains(p, want) {
t.Errorf("permission modal missing %q", want)
}
@@ -236,69 +238,24 @@ func TestPermLayoutMatchesRenderClamped(t *testing.T) {
}
}
func TestToolResultRenderingCollapsesAndShows(t *testing.T) {
func TestToolCardsShowContent(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 40,
Rows: []Row{
{Kind: "toolcall", Tool: "read_file", Detail: "README.md"},
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: README.md (217 lines)\n\n 1 | THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR"},
{Kind: "toolcall", Tool: "list_directory", Detail: "."},
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of .:\n\na\nb\nc"},
{Kind: "toolcall", Tool: "edit_file", Detail: "x.go"},
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
{Kind: "toolcall", Tool: "bash", Detail: "go test"},
{Kind: "toolresult", Tool: "bash", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
{Kind: "tool", Tool: "read_file", Text: "README.md", Status: "ok", Detail: "line one\nline two\nline three"},
{Kind: "tool", Tool: "edit_file", Text: "x.go", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
{Kind: "tool", Tool: "bash", Text: "go test", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
},
}
out := stripANSI(RenderChat(d))
// file read collapses to a count, never dumps content
if !strings.Contains(out, "217 lines") {
t.Error("read_file should summarize to a line count")
if !strings.Contains(out, "line one") {
t.Error("read card should show file content")
}
if strings.Contains(out, "THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR") {
t.Error("read_file dumped raw file content (should be collapsed)")
}
// listing collapses to entry count
if !strings.Contains(out, "3 entries") {
t.Error("list_directory should summarize to an entry count")
}
// diff body is shown for edits
if !strings.Contains(out, "NEWCODE") {
t.Error("edit_file diff body should be shown")
t.Error("edit card should show the diff body")
}
// errors are surfaced
if !strings.Contains(out, "BUILD-FAILED-HERE") {
t.Error("error output should be shown")
}
}
func TestAssistantMarkdownClipsLongLines(t *testing.T) {
// glamour does not hard-break unbreakable tokens, so a very long URL or fenced
// code line would otherwise emit a line far wider than the frame, blowing out
// the fixed-height/full-bleed layout. Each emitted line must fit the budget.
s := newStyles(Resolve(0, true), 0, true)
tw := 60
longURL := "See https://example.com/" + strings.Repeat("verylongpath/", 40) + "end"
out := s.renderAssistantMarkdown(longURL, tw)
if len(out) == 0 {
t.Fatal("renderAssistantMarkdown returned no lines")
}
for i, ln := range out {
if w := lipgloss.Width(ln); w > tw {
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
}
}
}
func TestAssistantMarkdownClipsLongFencedCode(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
tw := 50
src := "```\n" + strings.Repeat("x", 300) + "\n```"
out := s.renderAssistantMarkdown(src, tw)
for i, ln := range out {
if w := lipgloss.Width(ln); w > tw {
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
}
if !strings.Contains(out, "BUILD-FAILED-HERE") || !strings.Contains(out, "✗") {
t.Error("bash error card should surface the error output + ✗ status")
}
}
+29 -2
View File
@@ -11,6 +11,10 @@ import "github.com/charmbracelet/lipgloss"
type Pal struct {
Bg, Panel, Panel2, Fg, Dim, Mute, Line, Line2 lipgloss.Color
Accent, Accent2, Green, Amber, Red, Sel lipgloss.Color
// Extended fields for the ZERO design: a third panel level, two extra
// dim/text levels (faint, faintest), and a blue accent. Legacy themes derive
// these in mkPal so new render code always has a non-empty value.
Panel3, Faint, Faintest, Blue lipgloss.Color
}
// Theme is a named color identity with a dark and a light variant.
@@ -23,12 +27,35 @@ type Theme struct {
func mkPal(bg, panel, panel2, fg, dim, mute, line, line2, accent, accent2, green, amber, red, sel string) Pal {
c := func(s string) lipgloss.Color { return lipgloss.Color(s) }
return Pal{c(bg), c(panel), c(panel2), c(fg), c(dim), c(mute), c(line), c(line2),
c(accent), c(accent2), c(green), c(amber), c(red), c(sel)}
p := Pal{
Bg: c(bg), Panel: c(panel), Panel2: c(panel2), Fg: c(fg), Dim: c(dim), Mute: c(mute),
Line: c(line), Line2: c(line2), Accent: c(accent), Accent2: c(accent2),
Green: c(green), Amber: c(amber), Red: c(red), Sel: c(sel),
}
// Derive the extended fields for legacy themes from existing colors.
p.Panel3 = p.Panel2
p.Faint = p.Mute
p.Faintest = p.Line2
p.Blue = p.Accent2
return p
}
// zeroPal is the ZERO design palette (exact hex from the spec). Dark-only design.
var zeroPal = Pal{
Bg: "#070708", Panel: "#0e0e10", Panel2: "#121215", Panel3: "#17171b",
Line: "#242429", Line2: "#2e2e34",
Fg: "#ececee", Dim: "#8b8b93", Mute: "#5b5b63", Faint: "#5b5b63", Faintest: "#3a3a40",
Accent: "#caff3f", Accent2: "#caff3f", Green: "#5dd1a4", Amber: "#ffc25c", Red: "#ff7a7a",
Blue: "#7db4ff", Sel: "#19220a",
}
// Themes is the ordered list of the 5 selectable color identities (keys 1-5).
var Themes = []Theme{
{
Name: "ZERO", Swt: "#caff3f",
Dark: zeroPal,
Light: zeroPal, // dark-only design
},
{
Name: "Phosphor", Swt: "#ffb000",
Dark: mkPal("#040804", "#0a0f0a", "#0e150e", "#dfe8d6", "#8aa07f", "#566b50", "#16241a", "#22382a", "#ffb000", "#36ff7a", "#36ff7a", "#ffb000", "#ff6b6b", "#171f12"),
+51
View File
@@ -0,0 +1,51 @@
package zeroline
import "testing"
// TestZeroThemeIsDefault asserts the new ZERO palette is Themes[0] (the Resolve
// default) with the exact hex values from the design spec.
func TestZeroThemeIsDefault(t *testing.T) {
if got := ThemeName(0); got != "ZERO" {
t.Fatalf("default theme name = %q, want ZERO", got)
}
p := Resolve(0, true)
got := map[string]string{
"Bg": string(p.Bg), "Panel": string(p.Panel), "Panel2": string(p.Panel2),
"Panel3": string(p.Panel3), "Line": string(p.Line), "Line2": string(p.Line2),
"Fg": string(p.Fg), "Dim": string(p.Dim), "Faint": string(p.Faint),
"Faintest": string(p.Faintest), "Accent": string(p.Accent), "Green": string(p.Green),
"Red": string(p.Red), "Amber": string(p.Amber), "Blue": string(p.Blue),
}
want := map[string]string{
"Bg": "#070708", "Panel": "#0e0e10", "Panel2": "#121215", "Panel3": "#17171b",
"Line": "#242429", "Line2": "#2e2e34", "Fg": "#ececee", "Dim": "#8b8b93",
"Faint": "#5b5b63", "Faintest": "#3a3a40", "Accent": "#caff3f", "Green": "#5dd1a4",
"Red": "#ff7a7a", "Amber": "#ffc25c", "Blue": "#7db4ff",
}
for k, w := range want {
if got[k] != w {
t.Errorf("ZERO.%s = %q, want %q", k, got[k], w)
}
}
}
// TestLegacyThemesPreserved asserts the 5 original themes remain reachable after
// ZERO is prepended, and that the extended Pal fields are populated for them so
// new render code never emits an empty color.
func TestLegacyThemesPreserved(t *testing.T) {
if len(Themes) != 6 {
t.Fatalf("len(Themes) = %d, want 6 (ZERO + 5 legacy)", len(Themes))
}
if got := ThemeName(1); got != "Phosphor" {
t.Fatalf("Themes[1] = %q, want Phosphor (legacy preserved)", got)
}
p := Resolve(1, true)
for name, v := range map[string]string{
"Panel3": string(p.Panel3), "Faint": string(p.Faint),
"Faintest": string(p.Faintest), "Blue": string(p.Blue),
} {
if v == "" {
t.Errorf("legacy theme missing extended field %s", name)
}
}
}
+2 -2
View File
@@ -13,8 +13,8 @@ func TestHumanTokens(t *testing.T) {
{1000, "1k"}, // .0 trimmed
{1234, "1.2k"},
{1500, "1.5k"},
{999999, "1000k"}, // just under 1M: rounds up but stays in k
{1000000, "1M"}, // .0 trimmed at the M boundary
{999999, "1000k"}, // just under 1M: rounds up but stays in k
{1000000, "1M"}, // .0 trimmed at the M boundary
{1500000, "1.5M"},
{5000000, "5M"},
{12345678, "12.3M"}, // large counts read in M, not thousands of k
+108
View File
@@ -0,0 +1,108 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestToolCardDiff(t *testing.T) {
diff := "+added line\n-removed line\n unchanged"
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Detail: diff, Status: "ok"}})))
if !strings.Contains(out, "╭") || !strings.Contains(out, "╰") {
t.Error("tool card missing rounded border")
}
for _, w := range []string{"edit_file", "internal/cli/root.go", "✓", "added line", "removed line"} {
if !strings.Contains(out, w) {
t.Errorf("diff card missing %q", w)
}
}
}
func TestToolCardBash(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go test ./...", Detail: "ok pkg 0.2s", Status: "ok"}})))
for _, w := range []string{"bash", "$ go test", "exit 0"} {
if !strings.Contains(out, w) {
t.Errorf("bash card missing %q in %q", w, out)
}
}
}
func TestToolCardBashError(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go vet", Detail: "vet: bad", Status: "error"}})))
if !strings.Contains(out, "✗") || !strings.Contains(out, "exit 1") {
t.Errorf("error bash card missing ✗/exit 1: %q", out)
}
}
func TestToolCardRunningSpinner(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: "pattern", Running: true}})))
found := false
for _, f := range spinFrames {
if strings.Contains(out, f) {
found = true
break
}
}
if !found {
t.Errorf("running card missing spinner: %q", out)
}
}
func TestToolCardGrep(t *testing.T) {
body := "internal/a.go:41:foo match\ninternal/b.go:12:bar match"
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})))
for _, w := range []string{"a.go", "foo match", "2 matches"} {
if !strings.Contains(out, w) {
t.Errorf("grep card missing %q in %q", w, out)
}
}
}
func TestToolCardGrepTabsNoOverflow(t *testing.T) {
body := "a.go:1:if\tx\t{\treturn\tnil}\nb.go:2:foo\tbar\tbaz"
d := chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})
d.Width, d.Height = 60, 16
out := RenderChat(d)
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 60 {
t.Fatalf("grep line with tabs overflows width 60: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
}
func TestToolCardLongNameNoHeadOverflow(t *testing.T) {
long := "mcp__some_very_long_server__some_very_long_tool_name_indeed"
d := chatWith([]Row{{Kind: "tool", Tool: long, Text: "and/a/long/target/path/to/some/file.go", Status: "ok", Detail: "x"}})
d.Width, d.Height = 70, 14
out := RenderChat(d)
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 70 {
t.Fatalf("long tool name overflows card head: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
}
func TestDiffBodyStatSkipsHeaders(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
diff := "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new"
out := stripANSI(strings.Join(s.diffBody(diff, 60), "\n"))
if !strings.Contains(out, "+1") || !strings.Contains(out, "-1") {
t.Fatalf("stat should be +1/-1 with diff headers excluded, got: %q", out)
}
}
func TestToolCardFrameExact(t *testing.T) {
d := chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "f.go", Detail: "+x\n-y\n z", Status: "ok"}})
d.Width, d.Height = 100, 26
out := RenderChat(d)
if h := lipgloss.Height(out); h != 26 {
t.Fatalf("chat height = %d, want 26 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("tool-card line exceeds width 100: %d", lipgloss.Width(line))
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func chatWith(rows []Row) ChatData {
return ChatData{Variant: 0, Dark: true, Width: 90, Height: 24, Header: Header{Model: "m"}, Rows: rows}
}
func TestTranscriptBlocks(t *testing.T) {
// user: accent ❯ + ink, no "you" label
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "refactor the loop"}})))
if !strings.Contains(out, "❯ refactor the loop") {
t.Errorf("user block missing ❯ + text")
}
if strings.Contains(out, "you ") {
t.Errorf("user block should not show the 'you' label")
}
// assistant say: muted text, no "✦ zero" label
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "Here is the plan."}})))
if !strings.Contains(out, "Here is the plan.") {
t.Errorf("say text missing")
}
if strings.Contains(out, "✦ zero") {
t.Errorf("say block should not show the zero label")
}
// final: accent rail + ink text
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All done."}})))
if !strings.Contains(out, "│") || !strings.Contains(out, "All done.") {
t.Errorf("final block missing rail/text: %q", out)
}
// done: ■ + faint meta
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "done", Text: "12 tools · 1,284 tok · $0.04", Status: "ok"}})))
if !strings.Contains(out, "■") || !strings.Contains(out, "12 tools") {
t.Errorf("done block missing dot/meta: %q", out)
}
// notes: sys + deny
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "compacted older turns"}}))); !strings.Contains(out, "compacted older turns") {
t.Errorf("sys note missing")
}
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "error", Text: "denied: bash"}}))); !strings.Contains(out, "denied: bash") {
t.Errorf("deny note missing")
}
}
func TestStreamingCaretOnlyMidStream(t *testing.T) {
// Streaming → accent caret ▌ trails the say text.
d := chatWith(nil)
d.Stream = "partial answer"
d.Working = true
stream := stripANSI(RenderChat(d))
if !strings.Contains(stream, "partial answer") || !strings.Contains(stream, "▌") {
t.Errorf("streaming should show text + caret: %q", stream)
}
// Not streaming → no caret.
still := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "settled answer"}})))
if strings.Contains(still, "▌") {
t.Errorf("non-streaming transcript must not show the caret")
}
}
func TestMultiLineNotePreserved(t *testing.T) {
// A multi-line system note (e.g. a resume-session summary) must keep all lines.
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "Resumed session\nid: abc123\nmodel: opus"}})))
for _, want := range []string{"Resumed session", "id: abc123", "model: opus"} {
if !strings.Contains(out, want) {
t.Errorf("multi-line note dropped %q", want)
}
}
}
func TestStreamingPreservesNewlines(t *testing.T) {
d := chatWith(nil)
d.Stream = "first paragraph\n\nsecond paragraph"
d.Working = true
out := stripANSI(RenderChat(d))
if !strings.Contains(out, "first paragraph") || !strings.Contains(out, "second paragraph") {
t.Errorf("streaming flattened line structure: %q", out)
}
}
func TestLongTokenReclipped(t *testing.T) {
// wrap() does not hard-break a single long word; the block helpers must re-clip.
s := newStyles(Resolve(0, true), 0, true)
for _, line := range s.renderFinal(strings.Repeat("X", 300), 74, false) {
if lipgloss.Width(line) > 76 { // rail "│ " (2) + 74
t.Fatalf("renderFinal line overflows budget: %d cells", lipgloss.Width(line))
}
}
for _, line := range s.renderSay(strings.Repeat("Y", 300), 74, false) {
if lipgloss.Width(line) > 74 {
t.Fatalf("renderSay line overflows budget: %d cells", lipgloss.Width(line))
}
}
}
func TestComposerRunStopAffordance(t *testing.T) {
idle := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "hi"}})))
if !strings.Contains(idle, "run ↵") {
t.Errorf("idle composer should show 'run ↵', got: %q", idle)
}
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
d.Working = true
working := stripANSI(RenderChat(d))
if !strings.Contains(working, "■ stop") || strings.Contains(working, "run ↵") {
t.Errorf("working composer should show '■ stop' not 'run ↵', got: %q", working)
}
}
func TestComposerButtonNotClippedByLongInput(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
d.Input = strings.Repeat("x", 200)
d.Width, d.Height = 50, 12
out := stripANSI(RenderChat(d))
if !strings.Contains(out, "run ↵") {
t.Errorf("run button must survive a long input (was clipped): %q", out)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 50 {
t.Fatalf("composer line overflows width 50: %d", lipgloss.Width(line))
}
}
}
func TestTranscriptFrameExact(t *testing.T) {
d := chatWith([]Row{
{Kind: "user", Text: "do the thing"},
{Kind: "assistant", Text: "Working on it."},
{Kind: "final", Text: "Finished."},
{Kind: "done", Text: "2 tools · 100 tok", Status: "ok"},
})
d.Width, d.Height = 100, 22
out := RenderChat(d)
if h := lipgloss.Height(out); h != 22 {
t.Fatalf("chat height = %d, want 22 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("chat line exceeds width 100: %d", lipgloss.Width(line))
}
}
}