mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6174b20226 | ||
|
|
4388807eee | ||
|
|
28d55e3fd8 | ||
|
|
16554508f5 | ||
|
|
84ff761544 | ||
|
|
a503b3a0ba | ||
|
|
df6194544b | ||
|
|
27b377186a | ||
|
|
31cab0d836 | ||
|
|
c8bdbad5a1 | ||
|
|
5b685861f1 | ||
|
|
9b1d02510c | ||
|
|
d69396323d | ||
|
|
30a9a060a5 | ||
|
|
65139c7b04 | ||
|
|
8c7c7e75ba | ||
|
|
1ded5ec253 | ||
|
|
f7f2c0c64a | ||
|
|
337bdd1a16 | ||
|
|
20a48e6af5 | ||
|
|
64f78875a9 | ||
|
|
75342a4a31 | ||
|
|
eee8036c52 | ||
|
|
0e1a5dde02 | ||
|
|
aa3b610109 | ||
|
|
318e6937d5 |
No files matched your search
+51
-18
@@ -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"}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+678
-433
File diff suppressed because it is too large.
Load diff
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user