diff --git a/git-hooks/spec-registry.txt b/git-hooks/spec-registry.txt index 03d16a6..9c6ce5b 100644 --- a/git-hooks/spec-registry.txt +++ b/git-hooks/spec-registry.txt @@ -50,3 +50,7 @@ 1.23 1 43 main tag 1.18 1 44 main tag 1.19 1 45 main tag +1.14 1 46 main skip +1.24 1 47 acceptance tag +1.4a 1 48 sub skip +1.25 1 49 main tag diff --git a/internal/app/cli/llmapp/bullet_test.go b/internal/app/cli/llmapp/bullet_test.go new file mode 100644 index 0000000..f012476 --- /dev/null +++ b/internal/app/cli/llmapp/bullet_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package llmapp + +import ( + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/block" +) + +// TestMarkAssistantBullet_FirstContentOnly: the bullet lands on the first +// non-empty content Message only; thinking/empty/tool nodes are skipped and +// the bullet is consumed (pending→false) once placed. +func TestMarkAssistantBullet_FirstContentOnly(t *testing.T) { + t.Parallel() + nodes := []block.RenderNode{ + block.Message{Empty: true}, // thinking-only placeholder: skip + block.Message{Text: "first answer"}, // ← gets the bullet + block.Message{Text: "second line same turn"}, // no bullet (per-turn) + } + out, pending := markAssistantBullet(nodes, true) + if pending { + t.Errorf("bullet should be consumed (pending=false) after placement") + } + m0 := out[0].(block.Message) + m1 := out[1].(block.Message) + m2 := out[2].(block.Message) + if m0.Speaker == block.SpeakerAssistant { + t.Errorf("empty placeholder must NOT get the bullet") + } + if m1.Speaker != block.SpeakerAssistant { + t.Errorf("first content node should get SpeakerAssistant, got %v", m1.Speaker) + } + if m2.Speaker == block.SpeakerAssistant { + t.Errorf("second content node must NOT get a bullet (per-turn)") + } +} + +// TestMarkAssistantBullet_StaysPendingNoContent: a drain with no content +// Message (e.g. thinking-only) keeps the bullet pending for a later drain in +// the same turn. +func TestMarkAssistantBullet_StaysPendingNoContent(t *testing.T) { + t.Parallel() + nodes := []block.RenderNode{block.Message{Empty: true}} + _, pending := markAssistantBullet(nodes, true) + if !pending { + t.Errorf("no content node → bullet must stay pending") + } +} + +// TestMarkAssistantBullet_NotPendingNoop: when not pending, nodes are +// untouched. +func TestMarkAssistantBullet_NotPendingNoop(t *testing.T) { + t.Parallel() + nodes := []block.RenderNode{block.Message{Text: "x"}} + out, pending := markAssistantBullet(nodes, false) + if pending { + t.Errorf("pending should remain false") + } + if out[0].(block.Message).Speaker == block.SpeakerAssistant { + t.Errorf("not-pending must not mark any node") + } +} diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index acdd760..a9031d1 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -65,6 +65,17 @@ type Options struct { // Options keeps spec-1.21 behavior; production turns it on via config. DedupEnabled bool DedupWindow int + + // spec-1.25 chrome fields. Welcome gates the startup welcome panel seed + // (D-2: option-gated so only the interactive bootstrap path seeds it — + // headless / error-fallback / test constructions leave it false). Cwd + // (already ~-abbreviated) + GitBranch feed the rich status bar + // (D-4 StatusSegments); both are read ONCE by the caller (no per-frame + // filesystem IO) and cached on the Model. + Welcome bool + Cwd string + Version string + GitBranch string } // Model is the spec-1.20 production chat Model (replaces demoapp); under @@ -93,6 +104,13 @@ type Model struct { sawThinking bool // any thinking-channel token this turn (spec-1.20.2 D-5) thinkingBuf string // accumulated thinking text when strip_think=false + // assistantBulletPending marks that the current assistant turn has not yet + // emitted its ⏺ speaker bullet (spec-1.25 D-5 / CRIT-A). Set at submit; + // the first drained content Message of the turn consumes it (per-turn, not + // per-line). render/streaming stays neutral — the bullet is a post-drain + // render-only decoration applied here in llmapp. + assistantBulletPending bool + // toolUseNames joins ToolResult.ToolUseID → ToolUse.Name within a // single submit (spec-1.21 D-6 / spec-1.9b R3 HIGH-3: rendering a // ToolResult requires the peer ToolUse.Name; empty → skip render @@ -105,6 +123,12 @@ type Model struct { // captured by the snapshotBuilder and sealed at EventFinish. /report reads // it (spec-1.23 D-3/D-4). nil until the first completed run. lastSnapshot *report.RunSnapshot + + // spec-1.25 D-4 cached status fields (read once at New; StatusSegments + // reads these on every frame with zero filesystem IO). cwd is already + // ~-abbreviated; gitBranch is "" when not in a git repo / detached. + cwd string + gitBranch string } var ( @@ -141,7 +165,7 @@ func New(provider llm.Provider, opts Options) *Model { // at wiring time; tests would catch this immediately. panic("llmapp.New: " + err.Error()) } - return &Model{ + m := &Model{ provider: provider, loop: loop, modelName: opts.ModelName, @@ -151,9 +175,27 @@ func New(provider llm.Provider, opts Options) *Model { stripThink: opts.StripThink, thinkingMode: opts.ThinkingMode, thinkingBudget: opts.ThinkingBudget, + cwd: opts.Cwd, + gitBranch: opts.GitBranch, + } + // spec-1.25 D-2: option-gated welcome seed at scrollback head. Only the + // interactive bootstrap path sets Welcome=true (Q6 ★A construct-time seed: + // deterministic, no first-frame gap, no Cmd timing). The welcome is a + // plain render-only node that scrolls with history and is NOT re-seeded + // on /clear (Q-life ★A CC parity). + if opts.Welcome { + m.scrollback = []block.RenderNode{ + block.NewWelcome(opts.Version, opts.Cwd, welcomeTip), + } } + return m } +// welcomeTip is the single static onboarding tip shown in the welcome +// panel (spec-1.25 D-1; non-random so the render is replayable). DB-flavored +// and capability-honest (no reference to unbuilt slash commands). +const welcomeTip = "提示:直接用自然语言描述你的数据库问题即可开始诊断" + // Init has no startup Cmd. func (m *Model) Init() scheduler.Cmd { return nil } @@ -198,6 +240,7 @@ func (m *Model) Update(msg scheduler.Msg) (program.Model, scheduler.Cmd) { if nodes := next.stream.Drain(); len(nodes) > 0 { nodes = filterThinkingOnlyEmpty(nodes, next.sawThinking, next.sawContent) if len(nodes) > 0 { + nodes, next.assistantBulletPending = markAssistantBullet(nodes, next.assistantBulletPending) next.scrollback = appendNodes(next.scrollback, nodes) } } @@ -208,6 +251,7 @@ func (m *Model) Update(msg scheduler.Msg) (program.Model, scheduler.Cmd) { next.streaming = false next.sawThinking = false next.thinkingBuf = "" + next.assistantBulletPending = false // turn over; do not leak bullet return &next, nil } return m, nil @@ -243,7 +287,11 @@ func (m *Model) handleAction(msg program.KeyActionMsg) (program.Model, scheduler // guard at the top of handleAction already blocks any submit mid-run, // so /report cannot race a live diagnosis.) if isReportCommand(next.buffer) { - return &next, next.dispatchReport() + next.buffer = "" + next.cursor = 0 + nodes, cmd := next.dispatchReport() + next.scrollback = appendNodes(next.scrollback, nodes) + return &next, cmd } return next.submit() case keybindings.ActionCancel: @@ -281,7 +329,11 @@ func (m *Model) submit() (program.Model, scheduler.Cmd) { Role: llm.RoleUser, Content: []llm.ContentBlock{{Type: llm.BlockText, Text: userText}}, }, m.maxHistory) - next.scrollback = appendNode(m.scrollback, block.Message{Text: "> " + userText}) + // spec-1.25 D-5: user echo is plain (no "> " prefix — CC parity), tagged + // SpeakerUser. Starting the assistant turn arms the ⏺ bullet for the first + // content node drained (CRIT-A per-turn). + next.scrollback = appendNode(m.scrollback, block.Message{Text: userText, Speaker: block.SpeakerUser}) + next.assistantBulletPending = true next.toolUseNames = map[string]string{} // reset per submit (spec-1.21 D-6) return &next, loopStartCmd(ctx, m.loop, req, userText, ts, ctrl, m.stripThink) @@ -330,6 +382,16 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm next.thinkingBuf += msg.ThinkingToken } case msg.ToolUse != nil: + // spec-1.25 D-5 / codex D5-HIGH-1 (DEFERRED): draining pending stream + // text here to order assistant prose before the tool node is NOT done — + // the TokenStream is shared across all loop turns and the loop goroutine + // can race ahead, so a boundary drain pulls FUTURE-turn text before this + // tool node (proven by TestModel_LoopAppendsToolBlocks failing under CI + // timing). A correct fix needs producer-side ordering (per-turn stream + // boundary or text-via-ordered-channel) — tracked as a spec-1.21 + // follow-up. The ⏺ bullet still attaches correctly via markAssistantBullet + // at the View/streamDoneMsg drain. + // // Mutate the per-submit map in place — next is already a shallow // copy and toolUseNames is owned by this in-flight submit. if next.toolUseNames == nil { @@ -469,6 +531,7 @@ func (m *Model) View(cols, rows int) buffer.Buffer { if m.stream != nil { if nodes := m.stream.Drain(); len(nodes) > 0 { nodes = filterThinkingOnlyEmpty(nodes, m.sawThinking, m.sawContent) + nodes, m.assistantBulletPending = markAssistantBullet(nodes, m.assistantBulletPending) m.scrollback = append(m.scrollback, nodes...) } } @@ -497,13 +560,26 @@ func (m *Model) InputState() program.InputState { return program.InputState{Buffer: m.buffer, Cursor: m.cursor} } -// StatusSegments shows the model name + a streaming indicator. +// StatusSegments shows model · cwd · git-branch + a streaming indicator +// (spec-1.25 D-4, CC PromptInputFooter parity). cwd + gitBranch are cached +// at New (read once from the filesystem by the interactive bootstrap) — this +// method performs NO filesystem IO, since it runs on every render frame. +// token/context values are intentionally absent until spec-3.8/3.10 wire +// them (原则 3: no fake values). The mode segment is appended by +// program.paintStatusLine (spec-1.16 D-5 append-only contract). func (m *Model) StatusSegments() []program.StatusSegment { name := m.modelName if name == "" { name = m.provider.Name() } + dim := style.Style{FG: style.Palette(8)} // terminal-relative grey (R-10) segs := []program.StatusSegment{{Text: name}} + if m.cwd != "" { + segs = append(segs, program.StatusSegment{Text: m.cwd, Style: dim}) + } + if m.gitBranch != "" { + segs = append(segs, program.StatusSegment{Text: m.gitBranch, Style: dim}) + } if m.streaming { segs = append(segs, program.StatusSegment{Text: "●", Style: style.Style{Bold: true}}) } @@ -551,6 +627,30 @@ func appendNodes(sb []block.RenderNode, nodes []block.RenderNode) []block.Render return next } +// markAssistantBullet tags the FIRST content Message in nodes with +// SpeakerAssistant when a bullet is pending, returning the (possibly +// modified) nodes and the still-pending flag (spec-1.25 D-5 / CRIT-A). +// Per-turn: only the first assistant content node of a turn carries the ⏺ +// bullet (CC AssistantTextMessage is per-message, not per-line). When no +// content Message is found (e.g. thinking-only / tool-only drain), the +// bullet stays pending for a later drain in the same turn. nodes are +// replaced by value (block.Message is a value type), not mutated in place. +func markAssistantBullet(nodes []block.RenderNode, pending bool) ([]block.RenderNode, bool) { + if !pending { + return nodes, false + } + for i, n := range nodes { + msg, ok := n.(block.Message) + if !ok || msg.Text == "" || msg.Empty { + continue + } + msg.Speaker = block.SpeakerAssistant + nodes[i] = msg + return nodes, false // consumed + } + return nodes, true // still pending +} + func filterThinkingOnlyEmpty(nodes []block.RenderNode, sawThinking, sawContent bool) []block.RenderNode { if !sawThinking || sawContent || len(nodes) == 0 { return nodes diff --git a/internal/app/cli/llmapp/report_cmd.go b/internal/app/cli/llmapp/report_cmd.go index 7f609ba..f1f3029 100644 --- a/internal/app/cli/llmapp/report_cmd.go +++ b/internal/app/cli/llmapp/report_cmd.go @@ -28,6 +28,10 @@ const ( reportStreamingBusyMsg = "诊断进行中,请等完成后再 /report。" reportWrittenPrefix = "报告已写入: " reportWriteFailPrefix = "报告已渲染,但写入文件失败: " + // reportHeaderTitle is the spec-1.25 D-7 SpeakerAssistant header prepended + // before the report Markdown node so the ⏺ bullet sits on a title line and + // the report body reads as an assistant turn (CC hierarchy parity). + reportHeaderTitle = "诊断报告" ) // reportWrittenMsg / reportWriteFailedMsg carry the result of the write Cmd. @@ -40,21 +44,25 @@ func isReportCommand(buffer string) bool { input.ValueWithoutPrefix(buffer) == reportCommandName } -// dispatchReport consumes a "/report" submission on the receiver (a *Model copy -// owned by handleAction). It clears the input, renders the report into -// scrollback (pure), and returns a Cmd that writes the file (IO deferred to the -// Cmd). A nil lastSnapshot yields a friendly note and no Cmd. -func (m *Model) dispatchReport() scheduler.Cmd { - m.buffer = "" - m.cursor = 0 +// dispatchReport renders a "/report" submission into scrollback NODES and a +// file-write Cmd, WITHOUT mutating the receiver (go-reviewer H-1 / code-reviewer +// MED-1: keep the Update-path immutability discipline compile-visible — the +// caller, holding its own *Model copy, clears the input and appends the nodes). +// A nil lastSnapshot yields a friendly note and no Cmd. +func (m *Model) dispatchReport() ([]block.RenderNode, scheduler.Cmd) { if m.lastSnapshot == nil { - m.scrollback = appendNode(m.scrollback, block.Message{Text: reportNoDiagnosisMsg}) - return nil + return []block.RenderNode{block.Message{Text: reportNoDiagnosisMsg}}, nil } now := time.Now() md := report.Generate(*m.lastSnapshot, now) - m.scrollback = appendNode(m.scrollback, block.NewMarkdown(md)) - return writeReportCmd(md, now) + // spec-1.25 D-7: prepend a SpeakerAssistant header so the ⏺ bullet sits on + // the title and the Markdown body indents naturally beneath it (Markdown + // has no Speaker field; a header Message is the composition seam). + nodes := []block.RenderNode{ + block.Message{Text: reportHeaderTitle, Speaker: block.SpeakerAssistant}, + block.NewMarkdown(md), + } + return nodes, writeReportCmd(md, now) } // writeReportCmd performs the (blocking) file write off the pure Update path. diff --git a/internal/app/cli/llmapp/statusinfo.go b/internal/app/cli/llmapp/statusinfo.go new file mode 100644 index 0000000..c7d773d --- /dev/null +++ b/internal/app/cli/llmapp/statusinfo.go @@ -0,0 +1,112 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File statusinfo.go — spec-1.25 D-4 status-bar info helpers. cwd +// abbreviation + git branch detection, read ONCE at startup by the +// interactive bootstrap (NOT on the render hot path). Stdlib only — no +// shell, no go-git dependency (CLAUDE.md 规则 6; Q2 ★A). git dirty state is +// out of scope for this spec (spec-1.25 §1.1 #7). + +package llmapp + +import ( + "os" + "path/filepath" + "strings" +) + +// AbbrevCwd returns a compact display form of dir for the status bar: +// "~/sub/path" when dir is under home, "~" for home itself, otherwise the +// basename. Empty dir → "". spec-1.25 D-4 (NIT: exact cwd formatting). +// os.PathSeparator is '/' on the supported darwin/linux platforms (CLAUDE.md +// § 0 + CI matrix); the "~/sub" join below assumes that. +func AbbrevCwd(dir, home string) string { + if dir == "" { + return "" + } + if home != "" { + if dir == home { + return "~" + } + if rel := strings.TrimPrefix(dir, home+string(os.PathSeparator)); rel != dir { + return "~" + string(os.PathSeparator) + rel + } + } + return filepath.Base(dir) +} + +// GitBranch returns the current branch name of the git repository +// containing dir, or "" when dir is not in a repo, HEAD is detached, or any +// read fails. It walks up from dir to locate ".git" (a directory for a +// normal repo, or a file "gitdir: " for a worktree/submodule), reads +// HEAD, and extracts the branch from "ref: refs/heads/". No shell, +// no fork, no dependency (spec-1.25 D-4 / Q2 ★A). Intended to be called +// once at startup; never on the render path (R-3). +func GitBranch(dir string) string { + gitPath := findGitPath(dir) + if gitPath == "" { + return "" + } + gitDir := resolveGitDir(gitPath) + if gitDir == "" { + return "" + } + head, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) // #nosec G304 -- spec-1.25 D-4: git metadata path derived from cwd discovery walk, not user input + if err != nil { + return "" + } + line := strings.TrimSpace(string(head)) + const refPrefix = "ref: refs/heads/" + if !strings.HasPrefix(line, refPrefix) { + return "" // detached HEAD (raw SHA) or unexpected form + } + return strings.TrimPrefix(line, refPrefix) +} + +// findGitPath walks up from dir looking for a ".git" entry (dir or file). +// Returns the path to it, or "" if none is found before the filesystem root. +func findGitPath(dir string) string { + if dir == "" { + return "" + } + cur := dir + for { + candidate := filepath.Join(cur, ".git") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(cur) + if parent == cur { + return "" // reached filesystem root + } + cur = parent + } +} + +// resolveGitDir returns the git directory for gitPath. When gitPath is a +// directory it is the git dir; when it is a file ("gitdir: "), the +// referenced path is returned (worktree/submodule form). +func resolveGitDir(gitPath string) string { + info, err := os.Stat(gitPath) + if err != nil { + return "" + } + if info.IsDir() { + return gitPath + } + data, err := os.ReadFile(gitPath) // #nosec G304 -- spec-1.25 D-4: .git path derived from cwd discovery walk, not user input + if err != nil { + return "" + } + line := strings.TrimSpace(string(data)) + const prefix = "gitdir: " + if !strings.HasPrefix(line, prefix) { + return "" + } + gitDir := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(filepath.Dir(gitPath), gitDir) + } + return gitDir +} diff --git a/internal/app/cli/llmapp/statusinfo_test.go b/internal/app/cli/llmapp/statusinfo_test.go new file mode 100644 index 0000000..25ef4fe --- /dev/null +++ b/internal/app/cli/llmapp/statusinfo_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package llmapp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAbbrevCwd(t *testing.T) { + cases := []struct { + name, dir, home, want string + }{ + {"under home", "/Users/me/opendbx", "/Users/me", "~/opendbx"}, + {"home itself", "/Users/me", "/Users/me", "~"}, + {"deep under home", "/Users/me/a/b", "/Users/me", "~/a/b"}, + {"outside home", "/etc/conf", "/Users/me", "conf"}, + {"empty dir", "", "/Users/me", ""}, + {"empty home falls back to basename", "/var/db", "", "db"}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := AbbrevCwd(c.dir, c.home); got != c.want { + t.Errorf("AbbrevCwd(%q,%q) = %q; want %q", c.dir, c.home, got, c.want) + } + }) + } +} + +func TestGitBranch_DirRepo(t *testing.T) { + root := t.TempDir() + gitDir := filepath.Join(root, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(gitDir, "HEAD"), "ref: refs/heads/spec-1.25\n") + // from a nested subdir, GitBranch walks up to find .git + sub := filepath.Join(root, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if got := GitBranch(sub); got != "spec-1.25" { + t.Errorf("GitBranch = %q; want spec-1.25", got) + } +} + +func TestGitBranch_Detached(t *testing.T) { + root := t.TempDir() + gitDir := filepath.Join(root, ".git") + _ = os.MkdirAll(gitDir, 0o755) + writeFile(t, filepath.Join(gitDir, "HEAD"), "9fceb02d0ae598e95dc970b74767f19372d61af8\n") + if got := GitBranch(root); got != "" { + t.Errorf("detached HEAD GitBranch = %q; want empty", got) + } +} + +func TestGitBranch_GitFileWorktree(t *testing.T) { + root := t.TempDir() + realGit := filepath.Join(root, "realgit") + _ = os.MkdirAll(realGit, 0o755) + writeFile(t, filepath.Join(realGit, "HEAD"), "ref: refs/heads/wt-branch\n") + // .git as a FILE pointing at the real gitdir (worktree/submodule form) + writeFile(t, filepath.Join(root, ".git"), "gitdir: "+realGit+"\n") + if got := GitBranch(root); got != "wt-branch" { + t.Errorf("worktree GitBranch = %q; want wt-branch", got) + } +} + +func TestGitBranch_NoRepo(t *testing.T) { + if got := GitBranch(t.TempDir()); got != "" { + t.Errorf("no-repo GitBranch = %q; want empty", got) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/cli/llmapp/view_offset_test.go b/internal/app/cli/llmapp/view_offset_test.go new file mode 100644 index 0000000..6eb0fcc --- /dev/null +++ b/internal/app/cli/llmapp/view_offset_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package llmapp + +import ( + "strings" + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/block" + "github.com/sqlrush/opendbx/internal/domain/llm/fake" +) + +// TestModel_View_WelcomeHeadVisible: a freshly seeded welcome (scrollback[0]) +// is visible in View when the scrollback is short. This is the spec-1.25 D-8 +// regression surface — the live render path is Model.View's bottom-up loop +// over the plain []block.RenderNode slice (NOT render/scrollback's +// VirtualScrollback, which is never instantiated in production / CRIT-C). +func TestModel_View_WelcomeHeadVisible(t *testing.T) { + t.Parallel() + m := New(fake.New(), Options{Welcome: true, Version: "v0.49.0", Cwd: "~/opendbx"}) + txt := gridText(m.View(80, 24)) + if !strings.Contains(txt, "Welcome to opendbx") { + t.Errorf("View should show the seeded welcome; got:\n%s", txt) + } +} + +// TestModel_View_LargeScrollbackClipsHead: with a tall scrollback the +// bottom-up loop clips the welcome head (y<0 short-circuit) and renders the +// tail without panic or overflow. Guards the D-8 perf/offset claim that the +// welcome at scrollback[0] does not get fully re-rendered every frame. +func TestModel_View_LargeScrollbackClipsHead(t *testing.T) { + t.Parallel() + m := New(fake.New(), Options{Welcome: true, Version: "v0.49.0", Cwd: "~/x"}) + for i := 0; i < 5000; i++ { + m.scrollback = append(m.scrollback, block.Message{Text: "line"}) + } + buf := m.View(80, 24) + if buf == nil { + t.Fatal("View returned nil on large scrollback") + } + cols, rows := buf.Size() + if cols != 80 || rows != 24 { + t.Errorf("View size = (%d,%d); want (80,24)", cols, rows) + } + // the welcome head is clipped (not visible) when scrollback is tall + if strings.Contains(gridText(buf), "Welcome to opendbx") { + t.Errorf("welcome head should be clipped off-screen with 5000 lines") + } +} diff --git a/internal/app/cli/llmapp/welcome_seed_test.go b/internal/app/cli/llmapp/welcome_seed_test.go new file mode 100644 index 0000000..1622a6e --- /dev/null +++ b/internal/app/cli/llmapp/welcome_seed_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package llmapp + +import ( + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/block" + "github.com/sqlrush/opendbx/internal/domain/llm/fake" +) + +// TestWelcomeSeed_OnlyWhenEnabled is the spec-1.25 D-2 canary: a bare +// New (Welcome=false — the default for headless / error-fallback / test +// constructions) seeds NO welcome node, while Welcome=true seeds exactly +// one block.Welcome at scrollback head with the supplied version/cwd. +func TestWelcomeSeed_OnlyWhenEnabled(t *testing.T) { + t.Parallel() + + // default: no welcome + bare := New(fake.New(), Options{ModelName: "fake"}) + if len(bare.scrollback) != 0 { + t.Errorf("bare New must not seed welcome; scrollback len=%d", len(bare.scrollback)) + } + + // interactive: welcome seeded at head + withW := New(fake.New(), Options{ + ModelName: "fake", + Welcome: true, + Version: "v0.49.0", + Cwd: "~/opendbx", + GitBranch: "spec-1.25", + }) + if len(withW.scrollback) != 1 { + t.Fatalf("Welcome=true must seed exactly one node; got %d", len(withW.scrollback)) + } + w, ok := withW.scrollback[0].(block.Welcome) + if !ok { + t.Fatalf("scrollback[0] = %T; want block.Welcome", withW.scrollback[0]) + } + if w.Version != "v0.49.0" || w.Cwd != "~/opendbx" { + t.Errorf("welcome fields = %+v; want version/cwd populated", w) + } + // cwd + gitBranch are cached for the status bar (zero per-frame IO). + if withW.cwd != "~/opendbx" || withW.gitBranch != "spec-1.25" { + t.Errorf("status cache = (%q,%q); want (~/opendbx, spec-1.25)", withW.cwd, withW.gitBranch) + } +} + +// TestStatusSegments_RichChrome verifies the status bar carries model · cwd +// · git (spec-1.25 D-4) and never includes a token/context value +// (原则 3 — those are deferred to spec-3.8/3.10). +func TestStatusSegments_RichChrome(t *testing.T) { + t.Parallel() + m := New(fake.New(), Options{ModelName: "deepseek", Cwd: "~/opendbx", GitBranch: "main"}) + segs := m.StatusSegments() + var texts []string + for _, s := range segs { + texts = append(texts, s.Text) + } + want := map[string]bool{"deepseek": false, "~/opendbx": false, "main": false} + for _, txt := range texts { + if _, ok := want[txt]; ok { + want[txt] = true + } + } + for k, seen := range want { + if !seen { + t.Errorf("status segments missing %q; got %v", k, texts) + } + } +} diff --git a/internal/app/cli/program/layout.go b/internal/app/cli/program/layout.go index de0c056..5c1838e 100644 --- a/internal/app/cli/program/layout.go +++ b/internal/app/cli/program/layout.go @@ -4,38 +4,81 @@ package program -// Layout describes the current terminal geometry and the three-zone -// split: scrollback occupies rows [0, Rows-2), the input row sits at -// Rows-2, the status line at Rows-1. +// inputBoxMinRows is the spec-1.25 §4.1 N=5 correctness floor for the +// 3-row top/bottom-rule input box: status(1) + box(3) + >=1 scrollback +// row. Below it the input degrades to the spec-1.16 single-row form so +// ScrollbackSize never goes negative (R-1 graceful). +const inputBoxMinRows = 5 + +// Layout describes the current terminal geometry. spec-1.25 D-3: when +// Rows >= inputBoxMinRows the input is a 3-row top/bottom-rule box (CC +// PromptInput.tsx:2268 borderLeft/Right=false parity) occupying rows +// [Rows-4, Rows-1): top rule (Rows-4) / content (Rows-3) / bottom rule +// (Rows-2); the status line sits at Rows-1 and scrollback at [0, Rows-4). +// Below the floor it degrades to the spec-1.15/1.16 single-row split +// (input Rows-2, status Rows-1, scrollback [0, Rows-2)). // -// spec-1.16/1.17 will extend layout to multi-row input + side panels -// via LayoutPlugin; baseline 3 zones are hardcoded for spec-1.15. +// LayoutPlugin (below) remains an unwired forward-link seam (spec-1.17 +// side panels); spec-1.25 deliberately uses the concrete accessors here +// rather than the plugin (R2 CRIT-B: plugin has no consumer). type Layout struct { Cols, Rows int } +// BoxMode reports whether the terminal is tall enough for the 3-row +// input box (Rows >= inputBoxMinRows). Callers paint the single-row +// input when false. +func (l Layout) BoxMode() bool { + return l.Rows >= inputBoxMinRows +} + // ScrollbackSize returns the cols × rows available for Model.View -// rendering — the entire terminal width with the bottom two rows -// reserved for input + status. When the terminal is too small to fit -// all three zones (Rows < 3) ScrollbackSize collapses gracefully. +// rendering: full width with the bottom zones reserved (4 rows in box +// mode: top/content/bottom rule + status; 2 rows in single-row mode). +// Collapses gracefully (never negative) on tiny terminals. func (l Layout) ScrollbackSize() (cols, rows int) { cols = l.Cols - rows = l.Rows - 2 + if l.BoxMode() { + rows = l.Rows - 4 + } else { + rows = l.Rows - 2 + } if rows < 0 { rows = 0 } return } -// InputRow returns the row index of the input row (Rows-2). Returns -// -1 when the terminal is too small (Rows < 2). +// InputRow returns the row index of the input CONTENT row (where the +// buffer + cursor are painted) in both modes: Rows-3 in box mode, +// Rows-2 in single-row mode. Returns -1 when too small (Rows < 2). func (l Layout) InputRow() int { + if l.BoxMode() { + return l.Rows - 3 + } if l.Rows < 2 { return -1 } return l.Rows - 2 } +// InputBoxTop returns the top-rule row (Rows-4) in box mode, else -1. +func (l Layout) InputBoxTop() int { + if !l.BoxMode() { + return -1 + } + return l.Rows - 4 +} + +// InputBoxBottom returns the bottom-rule row (Rows-2) in box mode, +// else -1. +func (l Layout) InputBoxBottom() int { + if !l.BoxMode() { + return -1 + } + return l.Rows - 2 +} + // StatusLine returns the row index of the status line (Rows-1). // Returns -1 when the terminal has zero rows. func (l Layout) StatusLine() int { diff --git a/internal/app/cli/program/program.go b/internal/app/cli/program/program.go index 4c7f869..2e9ee66 100644 --- a/internal/app/cli/program/program.go +++ b/internal/app/cli/program/program.go @@ -338,14 +338,70 @@ func (p *Program) renderFn(next *buffer.Grid) { im, hasInp = v, true state = v.InputState() } - if r := layout.InputRow(); r >= 0 { - p.paintInputRow(next, r, im, state, hasInp) - } + p.paintInputBox(next, layout, im, state, hasInp) if r := layout.StatusLine(); r >= 0 { p.paintStatusLine(next, r, im, state, hasInp) } } +// inputBoxHint is the CC borderText hint embedded in the bottom rule +// (CC PromptInputFooterLeftSide.tsx:411 "? for shortcuts"). spec-1.25 D-3. +const inputBoxHint = "? for shortcuts" + +// inputBoxRune is the horizontal rule rune for the top/bottom input +// borders. spec-1.25 D-3: CC PromptInput.tsx:2268 uses borderStyle="round" +// with borderLeft/Right=false — i.e. top + bottom rules, no side walls. +const inputBoxRule = '─' + +// paintInputBox renders the spec-1.25 D-3 input region. In box mode +// (Layout.BoxMode) it draws a top rule, the content row (reusing +// paintInputRow so spec-1.16 mode/cursor/placeholder logic is unchanged), +// and a bottom rule carrying the CC borderText hint. Below the N=5 floor +// it degrades to a single content row (spec-1.16 parity). Painting nothing +// when the content row is absent (tiny terminal). +func (p *Program) paintInputBox(grid *buffer.Grid, layout Layout, im InputModel, state InputState, hasInput bool) { + content := layout.InputRow() + if content < 0 { + return + } + if !layout.BoxMode() { + p.paintInputRow(grid, content, im, state, hasInput) + return + } + cols, _ := grid.Size() + dim := style.Style{FG: style.Palette(8)} // terminal-relative grey (R-10) + if top := layout.InputBoxTop(); top >= 0 { + paintInputRule(grid, top, "", dim, cols) + } + p.paintInputRow(grid, content, im, state, hasInput) + if bottom := layout.InputBoxBottom(); bottom >= 0 { + paintInputRule(grid, bottom, inputBoxHint, dim, cols) + } +} + +// paintInputRule fills row y with the horizontal rule rune across cols +// and, when hint is non-empty, overlays " " near the right end +// (CC borderText). The surrounding spaces punch a gap in the rule so the +// hint reads as "──── ? for shortcuts ─". Rule-only when too narrow for +// the hint. spec-1.25 D-3. +func paintInputRule(grid *buffer.Grid, y int, hint string, st style.Style, cols int) { + if cols <= 0 || y < 0 { + return + } + for x := 0; x < cols; x++ { + grid.SetCell(x, y, buffer.Cell{Ch: inputBoxRule, St: st}) + } + if hint == "" { + return + } + label := " " + hint + " " + start := cols - width.Width(label) - 1 // keep 1 trailing rule cell + if start < 0 { + return // too narrow: rule only + } + paintTextAt(grid, label, start, y, st, cols) +} + // paintInputRow renders the input row at the given grid row. // // spec-1.16 R2 H-3 ★A: mode classification by input.DeriveMode(buffer) @@ -423,17 +479,21 @@ func (p *Program) paintStatusLine(grid *buffer.Grid, row int, _ InputModel, stat mode := input.DeriveMode(state.Buffer) segs = append(segs, StatusSegment{Text: mode.String()}) } + // spec-1.25 D-4: dim " · " separator BETWEEN segments (CC PromptInputFooter + // parity), not a trailing space after the last (codex D4-MED-1). + sep := style.Style{FG: style.Palette(8)} x := 0 - for _, seg := range segs { + for i, seg := range segs { if x >= cols { break } - x = paintTextAt(grid, seg.Text, x, row, seg.Style, cols) - // segment separator - if x < cols { - grid.SetCell(x, row, buffer.Cell{Ch: ' '}) - x++ + if i > 0 { + x = paintTextAt(grid, " · ", x, row, sep, cols) + if x >= cols { + break + } } + x = paintTextAt(grid, seg.Text, x, row, seg.Style, cols) } } diff --git a/internal/app/cli/program/program_test.go b/internal/app/cli/program/program_test.go index 0bc9802..f5b9eb4 100644 --- a/internal/app/cli/program/program_test.go +++ b/internal/app/cli/program/program_test.go @@ -446,39 +446,62 @@ func TestProgram_Cleanup_FiredOnExit(t *testing.T) { // --- Layout --- -func TestLayout_ScrollbackSize(t *testing.T) { +// TestLayout_OffsetTable freezes the spec-1.25 §4.1 offset math: input is a +// 3-row top/bottom-rule box when Rows>=5 (CC PromptInput.tsx:2268 parity), else +// it gracefully degrades to the spec-1.16 single-row input. N=5 is a +// correctness floor (status 1 + box 3 + >=1 scrollback row), NOT a tuning knob: +// below it ScrollbackSize would go negative. +func TestLayout_OffsetTable(t *testing.T) { cases := []struct { - name string - l Layout - wantCols int - wantRows int + name string + rows int + box bool // expect box mode + // expected geometry; -1 means "absent" + sbRows int + top int + content int // == InputRow() + bottom int + status int }{ - {"normal", Layout{Cols: 80, Rows: 24}, 80, 22}, - {"minimal", Layout{Cols: 80, Rows: 3}, 80, 1}, - {"too small", Layout{Cols: 80, Rows: 1}, 80, 0}, + {"box-24", 24, true, 20, 20, 21, 22, 23}, + {"box-5-floor", 5, true, 1, 1, 2, 3, 4}, + {"single-4", 4, false, 2, -1, 2, -1, 3}, + {"single-3", 3, false, 1, -1, 1, -1, 2}, + {"single-2", 2, false, 0, -1, 0, -1, 1}, + {"degenerate-1", 1, false, 0, -1, -1, -1, 0}, + {"degenerate-0", 0, false, 0, -1, -1, -1, -1}, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { - t.Parallel() // R2 N-3 - cols, rows := c.l.ScrollbackSize() - if cols != c.wantCols || rows != c.wantRows { - t.Errorf("got (%d,%d), want (%d,%d)", cols, rows, c.wantCols, c.wantRows) + t.Parallel() + l := Layout{Cols: 80, Rows: c.rows} + if got := l.BoxMode(); got != c.box { + t.Errorf("BoxMode = %v, want %v", got, c.box) + } + if cols, rows := l.ScrollbackSize(); cols != 80 || rows != c.sbRows { + t.Errorf("ScrollbackSize = (%d,%d), want (80,%d)", cols, rows, c.sbRows) + } + if got := l.InputBoxTop(); got != c.top { + t.Errorf("InputBoxTop = %d, want %d", got, c.top) + } + if got := l.InputRow(); got != c.content { + t.Errorf("InputRow(content) = %d, want %d", got, c.content) + } + if got := l.InputBoxBottom(); got != c.bottom { + t.Errorf("InputBoxBottom = %d, want %d", got, c.bottom) + } + if got := l.StatusLine(); got != c.status { + t.Errorf("StatusLine = %d, want %d", got, c.status) + } + // invariant: scrollback rows never negative (R-1 graceful) + if _, rows := l.ScrollbackSize(); rows < 0 { + t.Errorf("ScrollbackSize rows negative: %d", rows) } }) } } -func TestLayout_InputRow_StatusLine(t *testing.T) { - l := Layout{Cols: 80, Rows: 24} - if got := l.InputRow(); got != 22 { - t.Errorf("InputRow = %d, want 22", got) - } - if got := l.StatusLine(); got != 23 { - t.Errorf("StatusLine = %d, want 23", got) - } -} - // --- spec-1.4 R3 errata regression (T1-31..T1-37) --- func TestSchedulerR3_MsgIsAny(t *testing.T) { @@ -738,7 +761,7 @@ func TestProgram_InputRow_QuitArmedOverride(t *testing.T) { p.quitArmed = true grid, _ := buffer.NewGrid(40, 5) p.renderFn(grid) - got := rowToString(grid, 3) // InputRow + got := rowToString(grid, (Layout{Rows: 5}).InputRow()) // InputRow if !strings.Contains(got, "Press Ctrl+C again to quit") { t.Errorf("input row when quit armed = %q", got) } @@ -753,7 +776,7 @@ func TestProgram_InputRow_Natural_Prompt(t *testing.T) { p := New(drv, m) grid, _ := buffer.NewGrid(40, 5) p.renderFn(grid) - got := rowToString(grid, 3) + got := rowToString(grid, (Layout{Rows: 5}).InputRow()) if !strings.HasPrefix(got, "> hello") { t.Errorf("Natural input row = %q; want prefix '> hello'", got) } @@ -767,7 +790,7 @@ func TestProgram_InputRow_Slash_NoPrompt(t *testing.T) { p := New(drv, m) grid, _ := buffer.NewGrid(40, 5) p.renderFn(grid) - got := rowToString(grid, 3) + got := rowToString(grid, (Layout{Rows: 5}).InputRow()) if strings.HasPrefix(got, "> ") { t.Errorf("Slash input row = %q; should NOT have '> ' prompt", got) } @@ -783,7 +806,7 @@ func TestProgram_InputRow_SQL_NoPrompt(t *testing.T) { p := New(drv, m) grid, _ := buffer.NewGrid(40, 5) p.renderFn(grid) - got := rowToString(grid, 3) + got := rowToString(grid, (Layout{Rows: 5}).InputRow()) if strings.HasPrefix(got, "> ") { t.Errorf("SQL input row = %q; should NOT have '> ' prompt", got) } @@ -819,7 +842,7 @@ func TestProgram_InputRow_CursorGlyphAtPosition(t *testing.T) { p := New(drv, m) grid, _ := buffer.NewGrid(40, 5) p.renderFn(grid) - row := rowToString(grid, 3) // InputRow + row := rowToString(grid, (Layout{Rows: 5}).InputRow()) // InputRow if !strings.Contains(row, "_") { t.Errorf("row missing cursor glyph: %q", row) } @@ -847,7 +870,7 @@ func TestProgram_InputRow_ColsOverflowTruncate(t *testing.T) { grid, _ := buffer.NewGrid(10, 5) p.renderFn(grid) // 验证 row 不写出 col 10 以外 (paint 用 paintTextAt 自带 cols boundary) - row := rowToString(grid, 3) + row := rowToString(grid, (Layout{Rows: 5}).InputRow()) if len(row) > 10 { t.Errorf("row exceeded cols: len=%d, row=%q", len(row), row) } @@ -855,6 +878,58 @@ func TestProgram_InputRow_ColsOverflowTruncate(t *testing.T) { } } +// --- spec-1.25 D-3: top/bottom-rule input box --- + +// TestProgram_InputBox_RulesInBoxMode: when Rows>=5 the input is framed by +// a top rule (all '─') and a bottom rule carrying the "? for shortcuts" +// borderText (CC PromptInput.tsx:2268 parity), with the buffer on the +// content row between them. +func TestProgram_InputBox_RulesInBoxMode(t *testing.T) { + drv := newFakeDriver(40, 6) + m := &testModel{inputState: InputState{Buffer: "hi", Cursor: 2}} + p := New(drv, m) + grid, _ := buffer.NewGrid(40, 6) + p.renderFn(grid) + l := Layout{Cols: 40, Rows: 6} + top := rowToString(grid, l.InputBoxTop()) + content := rowToString(grid, l.InputRow()) + bottom := rowToString(grid, l.InputBoxBottom()) + if !strings.HasPrefix(top, "──────") || strings.ContainsAny(top, "?h") { + t.Errorf("top rule = %q; want all '─'", top) + } + if !strings.HasPrefix(content, "> hi") { + t.Errorf("content row = %q; want '> hi' prefix", content) + } + if !strings.Contains(bottom, "? for shortcuts") || !strings.HasPrefix(bottom, "──") { + t.Errorf("bottom rule = %q; want '─...─ ? for shortcuts ─'", bottom) + } +} + +// TestProgram_InputBox_SingleRowFallback: below the N=5 floor the input +// degrades to the spec-1.16 single-row form (no rules) so tiny terminals +// never produce negative scrollback (R-1 graceful). +func TestProgram_InputBox_SingleRowFallback(t *testing.T) { + drv := newFakeDriver(40, 4) + m := &testModel{inputState: InputState{Buffer: "hi", Cursor: 2}} + p := New(drv, m) + grid, _ := buffer.NewGrid(40, 4) + p.renderFn(grid) + l := Layout{Cols: 40, Rows: 4} + if l.BoxMode() { + t.Fatalf("Rows=4 must not be box mode") + } + content := rowToString(grid, l.InputRow()) // Rows-2 = 2 + if !strings.HasPrefix(content, "> hi") { + t.Errorf("single-row content = %q; want '> hi'", content) + } + // no rule rune anywhere above status in single-row mode + for y := 0; y < l.StatusLine(); y++ { + if strings.Contains(rowToString(grid, y), "───") { + t.Errorf("row %d unexpectedly contains a rule in single-row mode", y) + } + } +} + // R4 M-2: custom StatusSegmenter + mode segment dedup policy // (R-9: StatusSegmenter implementors MUST NOT include their own mode // segment; Program unconditionally appends). diff --git a/internal/app/cli/render/block/banner.go b/internal/app/cli/render/block/banner.go deleted file mode 100644 index ff694c2..0000000 --- a/internal/app/cli/render/block/banner.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2026 opendbx contributors. See LICENSE. -// -// Author: sqlrush - -package block - -import "github.com/sqlrush/opendbx/internal/app/cli/render/buffer" - -// Banner is the spec-0.13 stub for the banner block type. Render returns -// (nil, ErrUnsupportedNode) per the spec-0.13 D-3 contract (replaces R1 -// panic path; R2 codex HIGH-5). -// -// TODO(spec-TBD): see development-roadmap.md; not yet on Stage 1 roadmap. -type Banner struct{} - -// Render satisfies the RenderNode interface but returns the unsupported -// sentinel for this stub. -func (Banner) Render(_ Context) (buffer.Buffer, error) { - // errcode-lint:exempt -- spec-0.13 D-3: ErrUnsupportedNode is the registered sentinel; spec-1.7+ replaces with real Render. - return nil, ErrUnsupportedNode -} diff --git a/internal/app/cli/render/block/block_test.go b/internal/app/cli/render/block/block_test.go index c48ba07..9ec70f0 100644 --- a/internal/app/cli/render/block/block_test.go +++ b/internal/app/cli/render/block/block_test.go @@ -16,8 +16,8 @@ import ( // Promoted to production so far: Message (spec-1.7) / ToolUse (spec-1.9) // / ToolResult (spec-1.9b) / CompactSummary (spec-1.10) / Markdown // (spec-1.11) / Code (spec-1.12) / Diff (spec-1.13) / Thinking -// (spec-1.20.2). Remaining 2 stubs -// (Banner/Progress) preserve the spec-0.13 stub contract. +// (spec-1.20.2) / Welcome (spec-1.25, was Banner). Remaining 1 stub +// (Progress) preserves the spec-0.13 stub contract. // // spec-1.12 R2 codex L1: Code removed from stub list (promoted to // production). renderCodeBlock helper remains in code.go alongside @@ -28,7 +28,6 @@ func TestNonProductionStubs_ReturnUnsupported(t *testing.T) { name string node RenderNode }{ - {"banner", Banner{}}, {"progress", Progress{}}, } for _, c := range cases { diff --git a/internal/app/cli/render/block/message.go b/internal/app/cli/render/block/message.go index 5c02081..7b2134a 100644 --- a/internal/app/cli/render/block/message.go +++ b/internal/app/cli/render/block/message.go @@ -12,6 +12,7 @@ package block import ( + "runtime" "strings" "unicode/utf8" @@ -21,6 +22,37 @@ import ( "github.com/sqlrush/opendbx/internal/app/cli/render/width" ) +// SpeakerKind tags a Message with its conversational speaker (spec-1.25 D-5). +// The zero value SpeakerNone preserves the spec-1.7 FROZEN render (no prefix), +// so all existing block.Message{Text:...} construction sites are unaffected +// (规则 21). Speaker is a discriminator FIELD (not a separate block type) +// because user/assistant/system text share the same data shape — a string + +// wrap/markers — unlike ToolUse vs ToolResult which have genuinely different +// shapes and so are separate types (memory feedback_cc_block_design_bifurcation). +type SpeakerKind int + +const ( + // SpeakerNone renders the text with no speaker decoration (default). + SpeakerNone SpeakerKind = iota + // SpeakerAssistant prefixes the first line with the ⏺ bullet (CC + // AssistantTextMessage.tsx:232 BLACK_CIRCLE — per message, not per line) + // and hanging-indents continuation lines by 2. + SpeakerAssistant + // SpeakerUser renders plain (no prefix), matching CC's user echo. It + // carries no visual change vs SpeakerNone; the tag documents provenance + // and drops the legacy "> " prefix that the caller used to prepend. + SpeakerUser +) + +// speakerBullet is the assistant speaker glyph: ⏺ on darwin, ● elsewhere +// (CC figures.ts:4 BLACK_CIRCLE platform split; spec-1.25 Q7b). +var speakerBullet = func() rune { + if runtime.GOOS == "darwin" { + return '⏺' + } + return '●' +}() + // Message is the spec-0.13 D-3 message block type. spec-1.7 D-2 ships // production Render per spec-1.6 forward (R-9 4 fields + R-12 robust // fence + R2 D6 raw markers + R2 D11 Continued + 痛点 1.5 Empty). @@ -45,6 +77,9 @@ type Message struct { Truncated bool Continued bool Empty bool + // Speaker tags the conversational speaker (spec-1.25 D-5). Default + // SpeakerNone preserves spec-1.7 behavior. + Speaker SpeakerKind } // Render produces a Buffer per spec-1.7 D-2 (R2 forward batch). @@ -61,6 +96,9 @@ func (m Message) Render(ctx Context) (buffer.Buffer, error) { if ctx.Cols <= 0 { return measureOnlyBuf(0, 0), nil } + if m.Speaker == SpeakerAssistant && ctx.Cols > 2 { + return m.renderWithBullet(ctx) + } if m.Empty { return emptyPlaceholder(ctx), nil } @@ -76,6 +114,47 @@ func (m Message) Render(ctx Context) (buffer.Buffer, error) { return renderMixed(ctx, m, fences), nil } +// renderWithBullet renders a SpeakerAssistant Message: the inner content is +// rendered at width Cols-2, then composited into a Cols-wide buffer offset +// by 2 columns, with the ⏺ bullet written at (0,0). Continuation lines fall +// under the hanging indent (their first 2 cells stay blank). Per-turn bullet +// (CC AssistantTextMessage: the glyph appears once per message, on the first +// line — not per wrapped line). Empty content renders 0 rows (no orphan +// bullet). spec-1.25 D-5. +func (m Message) renderWithBullet(ctx Context) (buffer.Buffer, error) { + if m.Empty { + // A thinking-only placeholder must never carry the speaker bullet + // (go-reviewer M-3 / code-reviewer MED-2). markAssistantBullet already + // skips Empty nodes in production; this guards the public Message type + // against an orphan ⏺ on "(no output)" from any other construction site. + return measureOnlyBuf(ctx.Cols, 0), nil + } + inner := m + inner.Speaker = SpeakerNone + innerCtx := ctx + innerCtx.Cols = ctx.Cols - 2 + innerBuf, err := inner.Render(innerCtx) + if err != nil { + return nil, err + } + _, rows := innerBuf.Size() + if rows == 0 { + return measureOnlyBuf(ctx.Cols, 0), nil // no content → no bullet + } + if ctx.MeasureOnly { + return measureOnlyBuf(ctx.Cols, rows), nil + } + out, err := buffer.NewGrid(ctx.Cols, rows) + if err != nil { + return measureOnlyBuf(ctx.Cols, rows), nil + } + if g, ok := innerBuf.(*buffer.Grid); ok { + paint.BlitAt(out, g, 2, 0) + } + out.SetCell(0, 0, buffer.Cell{Ch: speakerBullet, St: themeOrDefault(ctx.Theme).Style(StyleNormal)}) + return out, nil +} + // renderPlainText renders Text with no fence detection. Applies // WrapPolicy then optionally appends Truncated/Continued marker. func renderPlainText(ctx Context, m Message) buffer.Buffer { diff --git a/internal/app/cli/render/block/message_bullet_test.go b/internal/app/cli/render/block/message_bullet_test.go new file mode 100644 index 0000000..db0e630 --- /dev/null +++ b/internal/app/cli/render/block/message_bullet_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package block + +import ( + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/buffer" +) + +// TestMessage_AssistantBullet: SpeakerAssistant prefixes the first line with +// the speaker bullet and hanging-indents wrapped continuation lines by 2, +// while the glyph appears only ONCE (per-message, not per-line). +func TestMessage_AssistantBullet(t *testing.T) { + t.Parallel() + // Force a wrap: narrow cols so the text spans >1 line. + m := Message{Text: "alpha beta gamma delta", Speaker: SpeakerAssistant} + buf, err := m.Render(Context{Cols: 12, Rows: 24, Wrap: WrapSoft}) + if err != nil { + t.Fatalf("render err: %v", err) + } + g := buf.(*buffer.Grid) + _, rows := g.Size() + if rows < 2 { + t.Fatalf("expected wrapped (>=2) rows, got %d", rows) + } + // line 0 col 0 == bullet; col 1 blank (gutter) + if got := g.Cell(0, 0).Ch; got != speakerBullet { + t.Errorf("line0 col0 = %q; want bullet %q", got, speakerBullet) + } + // content begins at col 2 + if got := g.Cell(2, 0).Ch; got != 'a' { + t.Errorf("line0 col2 = %q; want 'a' (content offset by bullet)", got) + } + // continuation lines have NO bullet at col 0 (blank gutter) + for y := 1; y < rows; y++ { + if got := g.Cell(0, y).Ch; got == speakerBullet { + t.Errorf("continuation row %d unexpectedly has a bullet", y) + } + } +} + +// TestMessage_UserAndNonePlain: SpeakerUser / SpeakerNone render identically +// (plain text, no bullet, no "> " prefix). +func TestMessage_UserAndNonePlain(t *testing.T) { + t.Parallel() + for _, sp := range []SpeakerKind{SpeakerNone, SpeakerUser} { + m := Message{Text: "hello", Speaker: sp} + buf, _ := m.Render(Context{Cols: 40, Rows: 24}) + g := buf.(*buffer.Grid) + if g.Cell(0, 0).Ch != 'h' { + t.Errorf("speaker %d: col0 = %q; want plain 'h'", sp, g.Cell(0, 0).Ch) + } + } +} + +// TestMessage_AssistantBulletEmptyNoOrphan: an empty-text assistant message +// renders 0 rows (no orphan bullet on a blank line). +func TestMessage_AssistantBulletEmptyNoOrphan(t *testing.T) { + t.Parallel() + // Both empty-text AND Empty=true (thinking placeholder) must yield 0 rows + // — no orphan ⏺ bullet (go-reviewer M-3 / code-reviewer MED-2). + cases := []Message{ + {Text: "", Speaker: SpeakerAssistant}, + {Empty: true, Speaker: SpeakerAssistant}, + } + for _, m := range cases { + buf, err := m.Render(Context{Cols: 40, Rows: 24}) + if err != nil { + t.Fatalf("render err: %v", err) + } + if _, rows := buf.Size(); rows != 0 { + t.Errorf("%+v rows = %d; want 0 (no orphan bullet)", m, rows) + } + } +} diff --git a/internal/app/cli/render/block/node.go b/internal/app/cli/render/block/node.go index 7290d7a..6b5aa3b 100644 --- a/internal/app/cli/render/block/node.go +++ b/internal/app/cli/render/block/node.go @@ -4,7 +4,7 @@ // Package block defines the RenderNode interface for all user-visible // rendered blocks (message / toolcall / toolresult / compact / markdown / -// code / diff / thinking / banner / progress) and the remaining unsupported stubs. +// code / diff / thinking / welcome / progress) and the remaining unsupported stubs. // // Each stub Render() returns (nil, ErrUnsupportedNode) — spec-1.7+ // fills the real implementation per block type. Renaming this from R1's @@ -97,5 +97,5 @@ type RenderNode interface { var ErrUnsupportedNode = errcode.Register( "RENDER.UNSUPPORTED_NODE", "block.Render called on unimplemented block type", - "this block type is not yet implemented; see spec-1.7+ block-type specs (message / toolcall / toolresult / compact / markdown / code / diff / thinking already implemented; banner / progress pending)", + "this block type is not yet implemented; see spec-1.7+ block-type specs (message / toolcall / toolresult / compact / markdown / code / diff / thinking / welcome already implemented; progress pending)", ) diff --git a/internal/app/cli/render/block/toolresult.go b/internal/app/cli/render/block/toolresult.go index e0bc936..6325b39 100644 --- a/internal/app/cli/render/block/toolresult.go +++ b/internal/app/cli/render/block/toolresult.go @@ -174,7 +174,19 @@ func (t ToolResult) Render(ctx Context) (buffer.Buffer, error) { return resultErrorPlaceholder(ctx, theme, t.ToolName) } - renderRows := expandToolResultRows(ctx, rows) + // spec-1.25 D-6: render result rows under a " ⎿ " connector (CC tree + // style, GrepTool/UI.tsx:66). Content is hanging-indented by connWidth so + // wrapped continuation lines align under the result text; the connector + // glyph occupies only the first row's gutter. When the terminal is too + // narrow for the connector, fall back to the un-indented spec-1.9b layout. + indentCtx := ctx + indent := toolResultConnWidth + if ctx.Cols <= indent { + indent = 0 // too narrow for the connector; render flush-left + } else { + indentCtx.Cols = ctx.Cols - indent + } + renderRows := expandToolResultRows(indentCtx, rows) if len(renderRows) == 0 { // CC null-return path: Success with no adapter rows → 0 rows. return measureOnlyBuf(ctx.Cols, 0), nil @@ -187,11 +199,23 @@ func (t ToolResult) Render(ctx Context) (buffer.Buffer, error) { return measureOnlyBuf(ctx.Cols, len(renderRows)), nil } for y, r := range renderRows { - writeTextRow(buf, 0, y, r.text, theme.Style(r.style), ctx.Cols) + writeTextRow(buf, indent, y, r.text, theme.Style(r.style), ctx.Cols) + } + if indent > 0 { + // " ⎿ " connector in the first row's gutter (cols 0..3 blank except ⎿). + buf.SetCell(2, 0, buffer.Cell{Ch: toolResultConnector, St: theme.Style(StyleDimmed)}) } return buf, nil } +// toolResultConnWidth is the gutter reserved for the " ⎿ " tree connector +// (2 spaces + ⎿ + space). spec-1.25 D-6. +const toolResultConnWidth = 4 + +// toolResultConnector is the CC tree connector rune joining a ToolUse header +// to its result (GrepTool/UI.tsx:66 dim " ⎿ "). +const toolResultConnector = '⎿' + // collectRows builds the logical rows per state, then appends the spec-1.22 // dedup "(cached)" marker when the result was served from cache. The marker is // a dim row of its own so it stays visible even for an empty-success hit whose diff --git a/internal/app/cli/render/block/toolresult_connector_test.go b/internal/app/cli/render/block/toolresult_connector_test.go new file mode 100644 index 0000000..6efff16 --- /dev/null +++ b/internal/app/cli/render/block/toolresult_connector_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package block + +import ( + "strings" + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/buffer" +) + +// TestToolResult_Connector: spec-1.25 D-6 renders the result under a " ⎿ " +// tree connector with content hanging-indented by 4 columns. +func TestToolResult_Connector(t *testing.T) { + t.Parallel() + tr := NewToolResult("tu1", "Bash", "done", false) + buf, err := tr.Render(Context{Cols: 60, Rows: 24}) + if err != nil { + t.Fatalf("render err: %v", err) + } + g := buf.(*buffer.Grid) + _, rows := g.Size() + if rows == 0 { + t.Fatal("expected >=1 row") + } + // connector rune ⎿ sits at column 2 of row 0 + if got := g.Cell(2, 0).Ch; got != toolResultConnector { + t.Errorf("col2 row0 = %q; want connector %q", got, toolResultConnector) + } + // columns 0,1 are blank gutter + for x := 0; x < 2; x++ { + if c := g.Cell(x, 0).Ch; c != 0 && c != ' ' { + t.Errorf("gutter col%d = %q; want blank", x, c) + } + } + // content (indicator + text) begins at column 4 + row0 := gridRow(t, buf, 0) + if !strings.Contains(row0, "done") { + t.Errorf("row0 = %q; want result text 'done' after connector", row0) + } + idx := strings.IndexRune(row0, '⎿') + if idx < 0 || !strings.Contains(row0[idx:], "done") { + t.Errorf("row0 = %q; want ⎿ before content", row0) + } +} + +// TestToolResult_ConnectorWrapHangingIndent: a result long enough to wrap +// hangs continuation rows under the connector — content at column 4, and the +// ⎿ glyph appears ONLY on the first row (spec-1.25 D-6 hanging-indent). +func TestToolResult_ConnectorWrapHangingIndent(t *testing.T) { + t.Parallel() + long := strings.Repeat("word ", 20) // forces multiple wrapped rows at narrow cols + tr := NewToolResult("tu1", "Bash", long, false) + buf, err := tr.Render(Context{Cols: 20, Rows: 24, Wrap: WrapSoft}) + if err != nil { + t.Fatalf("render err: %v", err) + } + g := buf.(*buffer.Grid) + _, rows := g.Size() + if rows < 2 { + t.Fatalf("expected wrapped result (>=2 rows), got %d", rows) + } + // row 0 carries the connector at col 2 + if g.Cell(2, 0).Ch != toolResultConnector { + t.Errorf("row0 col2 = %q; want connector", g.Cell(2, 0).Ch) + } + // continuation rows: NO connector anywhere, and content starts at col 4 + for y := 1; y < rows; y++ { + row := gridRow(t, buf, y) + if strings.ContainsRune(row, toolResultConnector) { + t.Errorf("continuation row %d has a connector glyph: %q", y, row) + } + // first 4 columns are the hanging-indent gutter (blank) + for x := 0; x < 4; x++ { + if c := g.Cell(x, y).Ch; c != 0 && c != ' ' { + t.Errorf("row %d col %d = %q; want blank gutter (hanging indent)", y, x, c) + } + } + } +} + +// TestToolResult_ConnectorNarrowFallback: when cols <= connector width the +// result renders flush-left (no connector) and never panics / overflows. +func TestToolResult_ConnectorNarrowFallback(t *testing.T) { + t.Parallel() + tr := NewToolResult("tu1", "Bash", "x", false) + buf, err := tr.Render(Context{Cols: 3, Rows: 24}) + if err != nil { + t.Fatalf("render err: %v", err) + } + g := buf.(*buffer.Grid) + cols, rows := g.Size() + for y := 0; y < rows; y++ { + // no row exceeds cols + w := 0 + for x := 0; x < cols; x++ { + if !buffer.IsContinuation(g.Cell(x, y)) && g.Cell(x, y).Ch != 0 { + w = x + 1 + } + } + if w > cols { + t.Errorf("row %d width %d exceeds cols %d", y, w, cols) + } + } +} diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go new file mode 100644 index 0000000..d925810 --- /dev/null +++ b/internal/app/cli/render/block/welcome.go @@ -0,0 +1,208 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File welcome.go — production Welcome panel block (spec-1.25 D-1). +// Renames + replaces the spec-0.13 Banner stub (which returned +// ErrUnsupportedNode; grep-confirmed zero production callers). Renders the +// classic CC-style boxed welcome `╭─ ✻ Welcome to opendbx ─╮` — a +// deliberate opendbx approximation of the pre-LogoV2 CC welcome (CC's +// current WelcomeV2.tsx is an ASCII-art logo; spec-1.25 §1.1 #5 keeps the +// boxed form). `✻` = CC figures.ts:6 TEARDROP_ASTERISK. The body carries +// the version, cwd, a single static tip, and the "? for shortcuts" hint +// (CC PromptInputFooterLeftSide.tsx:411). render-only: never enters the +// llm wire. + +package block + +import ( + "github.com/sqlrush/opendbx/internal/app/cli/render/buffer" + "github.com/sqlrush/opendbx/internal/app/cli/render/style" + "github.com/sqlrush/opendbx/internal/app/cli/render/width" +) + +// welcomeTitle is the boxed-welcome title text (embedded in the top rule). +const welcomeTitle = "✻ Welcome to opendbx" + +// welcomeShortcuts mirrors CC's "? for shortcuts" footer hint. +const welcomeShortcuts = "? for shortcuts" + +// Box-drawing runes for the rounded welcome frame. +const ( + bdTopLeft = '╭' + bdTopRight = '╮' + bdBottomLeft = '╰' + bdBottomRight = '╯' + bdHorizontal = '─' + bdVertical = '│' +) + +// Welcome is the spec-1.25 D-1 production welcome panel (replaces the +// spec-0.13 Banner stub). Zero-value Welcome{} renders a minimal frame +// (it does NOT return ErrUnsupportedNode — that was the stub contract). +type Welcome struct { + Version string // build version (internal/platform/version.String()) + Cwd string // working dir, already ~-abbreviated by the caller + Tip string // single static onboarding tip (non-random; replayable) +} + +// NewWelcome constructs a Welcome panel. Empty fields are simply omitted +// from the body (e.g. version="" drops the version line). +func NewWelcome(version, cwd, tip string) Welcome { + return Welcome{Version: version, Cwd: cwd, Tip: tip} +} + +// Render produces the boxed welcome Buffer. Graceful degradation: when +// Cols is too small for the frame it falls back to a single truncated +// line `✻ Welcome to opendbx ` (spec-1.25 D-1 / R-2). MeasureOnly +// returns the correct row count with no cell writes (spec-1.7 contract). +func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { + if ctx.Cols <= 0 { + return measureOnlyBuf(0, 0), nil + } + body := w.bodyLines() + boxW, ok := boxWidth(body, ctx.Cols) + if !ok { + return w.renderFallback(ctx), nil + } + rows := len(body) + 2 // top rule + body + bottom rule + if ctx.MeasureOnly { + return measureOnlyBuf(ctx.Cols, rows), nil + } + theme := themeOrDefault(ctx.Theme) + // The frame is left-aligned: cols [boxW, ctx.Cols) stay blank (terminal + // default). This is intentional (spec-1.25 §1.1 boxed-form scope), not a + // missing right margin (code-reviewer LOW-1). + buf, err := buffer.NewGrid(ctx.Cols, rows) + if err != nil { + return measureOnlyBuf(ctx.Cols, rows), nil + } + dim := theme.Style(StyleDimmed) + normal := theme.Style(StyleNormal) + + paintWelcomeTop(buf, boxW, dim, normal) + inner := boxW - 4 // content width between "│ " and " │" + for i, line := range body { + paintWelcomeBody(buf, i+1, boxW, inner, line, dim) + } + paintWelcomeBottom(buf, rows-1, boxW, dim) + return buf, nil +} + +// bodyLines builds the ordered, non-empty body content lines. +func (w Welcome) bodyLines() []string { + var lines []string + if w.Version != "" { + lines = append(lines, w.Version) + } + if w.Cwd != "" { + lines = append(lines, w.Cwd) + } + if w.Tip != "" { + lines = append(lines, w.Tip) + } + lines = append(lines, "") // blank spacer before the shortcuts hint + lines = append(lines, welcomeShortcuts) + return lines +} + +// renderFallback returns a single dim line "✻ Welcome to opendbx " +// truncated to Cols, used when the terminal is too narrow for the frame. +func (w Welcome) renderFallback(ctx Context) buffer.Buffer { + if ctx.MeasureOnly { + return measureOnlyBuf(ctx.Cols, 1) + } + line := welcomeTitle + if w.Version != "" { + line += " " + w.Version + } + buf, err := buffer.NewGrid(ctx.Cols, 1) + if err != nil { + return measureOnlyBuf(ctx.Cols, 1) + } + writeRunes(buf, 0, 0, line, themeOrDefault(ctx.Theme).Style(StyleDimmed), ctx.Cols) + return buf +} + +// boxWidth computes the frame width capped at cols. Returns ok=false when +// cols cannot fit even the minimal title frame (caller falls back to a +// single line). The frame must satisfy both the title rule (>= title+5: +// ╭─ title ─╮) and the widest body row (>= body+4: │ body │). +func boxWidth(body []string, cols int) (int, bool) { + // +6 = ╭ ─ space title space ─ ╮ : guarantees at least one trailing "─" + // after the title so the top renders ╭─ ✻ ... ─╮ (codex D1-LOW-1), not + // ╭─ ✻ ... ╮. + titleMin := width.Width(welcomeTitle) + 6 + bodyMin := 0 + for _, l := range body { + if bw := width.Width(l) + 4; bw > bodyMin { + bodyMin = bw + } + } + want := titleMin + if bodyMin > want { + want = bodyMin + } + if cols < titleMin { + return 0, false // too narrow even for the title frame + } + if want > cols { + want = cols // cap; body content truncates via writeRunes + } + return want, true +} + +// paintWelcomeTop writes ╭─ ─...─╮ across boxW cells. The title is +// StyleNormal; the frame runes are dim. +func paintWelcomeTop(buf *buffer.Grid, boxW int, dim, normal style.Style) { + buf.SetCell(0, 0, buffer.Cell{Ch: bdTopLeft, St: dim}) + buf.SetCell(1, 0, buffer.Cell{Ch: bdHorizontal, St: dim}) + buf.SetCell(2, 0, buffer.Cell{Ch: ' ', St: dim}) + x := writeRunes(buf, 3, 0, welcomeTitle, normal, boxW-1) + if x < boxW-1 { + buf.SetCell(x, 0, buffer.Cell{Ch: ' ', St: dim}) + x++ + } + for ; x < boxW-1; x++ { + buf.SetCell(x, 0, buffer.Cell{Ch: bdHorizontal, St: dim}) + } + buf.SetCell(boxW-1, 0, buffer.Cell{Ch: bdTopRight, St: dim}) +} + +// paintWelcomeBody writes │ <content padded to inner> │ at row y. +func paintWelcomeBody(buf *buffer.Grid, y, boxW, inner int, content string, dim style.Style) { + buf.SetCell(0, y, buffer.Cell{Ch: bdVertical, St: dim}) + buf.SetCell(1, y, buffer.Cell{Ch: ' ', St: dim}) + end := writeRunes(buf, 2, y, content, dim, 2+inner) + for x := end; x < boxW-1; x++ { + buf.SetCell(x, y, buffer.Cell{Ch: ' ', St: dim}) + } + buf.SetCell(boxW-1, y, buffer.Cell{Ch: bdVertical, St: dim}) +} + +// paintWelcomeBottom writes ╰─...─╯ across boxW cells at row y. +func paintWelcomeBottom(buf *buffer.Grid, y, boxW int, dim style.Style) { + buf.SetCell(0, y, buffer.Cell{Ch: bdBottomLeft, St: dim}) + for x := 1; x < boxW-1; x++ { + buf.SetCell(x, y, buffer.Cell{Ch: bdHorizontal, St: dim}) + } + buf.SetCell(boxW-1, y, buffer.Cell{Ch: bdBottomRight, St: dim}) +} + +// writeRunes writes text into buf at (x0,y) with style s, stopping before +// maxX (exclusive). Returns the end x. Wide-rune aware (render/width). +func writeRunes(buf *buffer.Grid, x0, y int, text string, s style.Style, maxX int) int { + x := x0 + for _, r := range text { + rw := width.RuneWidth(r) + if rw <= 0 { + continue + } + if x+rw > maxX { + break + } + buf.SetCell(x, y, buffer.Cell{Ch: r, St: s}) + x += rw + } + return x +} diff --git a/internal/app/cli/render/block/welcome_test.go b/internal/app/cli/render/block/welcome_test.go new file mode 100644 index 0000000..ee2c24b --- /dev/null +++ b/internal/app/cli/render/block/welcome_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package block + +import ( + "strings" + "testing" + + "github.com/sqlrush/opendbx/internal/app/cli/render/buffer" + "github.com/sqlrush/opendbx/internal/app/cli/render/width" +) + +// gridRow reads grid row y as a string (test helper local to welcome_test). +func gridRow(t *testing.T, buf buffer.Buffer, y int) string { + t.Helper() + g, ok := buf.(*buffer.Grid) + if !ok { + t.Fatalf("buffer is not *Grid: %T", buf) + } + cols, rows := g.Size() + if y < 0 || y >= rows { + t.Fatalf("row %d out of range [0,%d)", y, rows) + } + var b strings.Builder + for x := 0; x < cols; x++ { + c := g.Cell(x, y) + if buffer.IsContinuation(c) { + continue // wide-rune trailing cell; the lead cell already wrote the rune + } + if c.Ch == 0 { + b.WriteRune(' ') + continue + } + b.WriteRune(c.Ch) + } + return strings.TrimRight(b.String(), " ") +} + +func TestWelcome_BoxedRender(t *testing.T) { + w := NewWelcome("v0.49.0", "~/opendbx", "Try \"为什么这条 SQL 慢\"") + buf, err := w.Render(Context{Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Render err: %v", err) + } + g := buf.(*buffer.Grid) + cols, rows := g.Size() + if rows < 4 { + t.Fatalf("boxed welcome should have >=4 rows, got %d", rows) + } + top := gridRow(t, buf, 0) + bottom := gridRow(t, buf, rows-1) + // top has rounded corners + title; bottom has rounded corners. + if !strings.HasPrefix(top, "╭") || !strings.Contains(top, "✻ Welcome to opendbx") { + t.Errorf("top = %q; want ╭...✻ Welcome to opendbx...", top) + } + if !strings.HasPrefix(bottom, "╰") || !strings.HasSuffix(bottom, "╯") { + t.Errorf("bottom = %q; want ╰...╯", bottom) + } + // body carries version, cwd, tip, and the shortcuts hint. + all := "" + for y := 0; y < rows; y++ { + all += gridRow(t, buf, y) + "\n" + } + for _, want := range []string{"v0.49.0", "~/opendbx", "为什么这条 SQL 慢", "? for shortcuts"} { + if !strings.Contains(all, want) { + t.Errorf("welcome box missing %q; got:\n%s", want, all) + } + } + // no row exceeds Cols (rule 20 Layer 1 invariant) + for y := 0; y < rows; y++ { + if w := width.Width(gridRow(t, buf, y)); w > cols { + t.Errorf("row %d width %d exceeds cols %d", y, w, cols) + } + } +} + +func TestWelcome_NarrowFallbackSingleLine(t *testing.T) { + w := NewWelcome("v0.49.0", "~/opendbx", "tip") + // Cols too small for the box → single-line graceful fallback. + buf, err := w.Render(Context{Cols: 12, Rows: 24}) + if err != nil { + t.Fatalf("Render err: %v", err) + } + g := buf.(*buffer.Grid) + cols, rows := g.Size() + if rows != 1 { + t.Errorf("narrow fallback should be 1 row, got %d", rows) + } + row := gridRow(t, buf, 0) + if width.Width(row) > cols { + t.Errorf("fallback row width %d exceeds cols %d (%q)", width.Width(row), cols, row) + } + if !strings.Contains(row, "✻") { + t.Errorf("fallback should still carry ✻ marker, got %q", row) + } +} + +func TestWelcome_MeasureOnly(t *testing.T) { + w := NewWelcome("v0.49.0", "~/opendbx", "tip") + full, _ := w.Render(Context{Cols: 80, Rows: 24}) + _, wantRows := full.(*buffer.Grid).Size() + mo, err := w.Render(Context{Cols: 80, Rows: 24, MeasureOnly: true}) + if err != nil { + t.Fatalf("MeasureOnly err: %v", err) + } + _, gotRows := mo.Size() + if gotRows != wantRows { + t.Errorf("MeasureOnly rows %d != full rows %d", gotRows, wantRows) + } +} + +func TestWelcome_ZeroValueDegrades(t *testing.T) { + // Zero-value Welcome{} must not panic and must not return + // ErrUnsupportedNode (it is a production type now, not the spec-0.13 stub). + buf, err := Welcome{}.Render(Context{Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("zero-value Render err: %v", err) + } + if buf == nil { + t.Fatalf("zero-value Render returned nil buffer") + } +} + +func TestWelcome_ColsZero(t *testing.T) { + buf, err := NewWelcome("v", "c", "t").Render(Context{Cols: 0}) + if err != nil { + t.Fatalf("Cols=0 err: %v", err) + } + _, rows := buf.Size() + if rows != 0 { + t.Errorf("Cols=0 should yield 0 rows, got %d", rows) + } +} diff --git a/internal/bootstrap/tui_launcher.go b/internal/bootstrap/tui_launcher.go index bd0903d..0b4bc44 100644 --- a/internal/bootstrap/tui_launcher.go +++ b/internal/bootstrap/tui_launcher.go @@ -8,6 +8,7 @@ import ( "context" "errors" "log/slog" + "os" "sync" "github.com/gdamore/tcell/v2" @@ -22,6 +23,7 @@ import ( "github.com/sqlrush/opendbx/internal/domain/llm/fake" "github.com/sqlrush/opendbx/internal/platform/config" "github.com/sqlrush/opendbx/internal/platform/logger" + "github.com/sqlrush/opendbx/internal/platform/version" ) // newScreenFn is the screen factory for the program.Run production path @@ -153,12 +155,34 @@ func newChatModel() program.Model { DedupWindow: cfg.Diagnose.DedupWindow, } if perr != nil { - // Principle 3: explicit error, no demoapp fallback. + // Principle 3: explicit error, no demoapp fallback. Chrome (welcome + // + status cwd/git) is NOT seeded on the error path (spec-1.25 D-2: + // only the healthy interactive construction seeds it). return llmapp.New(fake.New().WithStartErr(perr), opts) } + // spec-1.25 D-2/D-4: seed the welcome panel + rich status bar on the + // healthy interactive path. cwd + git branch are read ONCE here (never on + // the render frame; R-3). os.Getwd failure simply omits the cwd line. + applyChromeOptions(&opts) return llmapp.New(provider, opts) } +// applyChromeOptions populates the spec-1.25 chrome fields on opts for the +// interactive TUI path: enables the welcome seed and reads cwd + git branch +// once (stdlib only, no shell — see llmapp.GitBranch). Kept off the +// error-fallback / headless paths so the welcome appears only on a healthy +// interactive launch (D-2 canary). +func applyChromeOptions(opts *llmapp.Options) { + cwd, err := os.Getwd() + if err != nil { + cwd = "" + } + opts.Welcome = true + opts.Version = version.String() + opts.Cwd = llmapp.AbbrevCwd(cwd, os.Getenv("HOME")) + opts.GitBranch = llmapp.GitBranch(cwd) +} + // stripThinkMigrationNoticeOnce is a per-process latch — the // once-emitted contract is process-wide so re-entering newChatModel // (tests, future hot-reload) does not spam the debug log.