From 75321d08bef701b737002104b6fec93d74eede72 Mon Sep 17 00:00:00 2001 From: sqlrush Date: Tue, 2 Jun 2026 17:15:55 +0800 Subject: [PATCH 01/10] feat(program): spec-1.25 D-3 top/bottom-rule input box + Layout offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout 3-zone → box mode (Rows>=5): top rule (Rows-4) / content (Rows-3) / bottom rule (Rows-2) + status (Rows-1); scrollback [0,Rows-4). N=5 是 correctness 闸,Rows<5 graceful 退化为 spec-1.16 单行 (ScrollbackSize 永不 负)。paintInputBox 复用 paintInputRow 内容逻辑 (无侧墙/无 Cols-4 内宽数学, CC PromptInput.tsx:2268 borderLeft/Right=false parity);bottom rule 嵌 "? for shortcuts" borderText。LayoutPlugin 仍 dead (forward-link),用具体 Layout.InputBox* API (R2 CRIT-B)。offset 表 §4.1 冻结 + 6 input 测读 content 行 (规则 21 caller 校正)。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/program/layout.go | 65 +++++++++-- internal/app/cli/program/program.go | 62 ++++++++++- internal/app/cli/program/program_test.go | 131 ++++++++++++++++++----- 3 files changed, 216 insertions(+), 42 deletions(-) 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..b34b651 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) 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). From 976c0c991aa18d8868cc54f2c0330f8c30ff8b29 Mon Sep 17 00:00:00 2001 From: sqlrush Date: Tue, 2 Jun 2026 17:42:55 +0800 Subject: [PATCH 02/10] feat(render): spec-1.25 D-1/D-2/D-4 welcome panel + status chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-1 block.Welcome (rename Banner→Welcome,无生产调用方;boxed ╭─ ✻ Welcome to opendbx ─╮ + version/cwd/tip/? for shortcuts;窄终端单行 graceful;MeasureOnly)。D-2 option-gated startup seed (llmapp.Options.Welcome, 仅 interactive bootstrap;headless/error-fallback/test 不 seed — canary 守)。 D-4 富状态条 model · cwd · git-branch (StatusSegments 读 cache 字段零 hot-path IO;statusinfo.go AbbrevCwd + GitBranch walk .git/HEAD,stdlib only 无 shell/ go-git,只 branch 不 dirty;无伪 token/context 值 原则 3)。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/model.go | 52 ++++- internal/app/cli/llmapp/statusinfo.go | 110 ++++++++++ internal/app/cli/llmapp/statusinfo_test.go | 85 ++++++++ internal/app/cli/llmapp/welcome_seed_test.go | 73 +++++++ internal/app/cli/render/block/banner.go | 21 -- internal/app/cli/render/block/block_test.go | 5 +- internal/app/cli/render/block/node.go | 4 +- internal/app/cli/render/block/welcome.go | 202 ++++++++++++++++++ internal/app/cli/render/block/welcome_test.go | 135 ++++++++++++ internal/bootstrap/tui_launcher.go | 26 ++- 10 files changed, 684 insertions(+), 29 deletions(-) create mode 100644 internal/app/cli/llmapp/statusinfo.go create mode 100644 internal/app/cli/llmapp/statusinfo_test.go create mode 100644 internal/app/cli/llmapp/welcome_seed_test.go delete mode 100644 internal/app/cli/render/block/banner.go create mode 100644 internal/app/cli/render/block/welcome.go create mode 100644 internal/app/cli/render/block/welcome_test.go diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index acdd760..a85bad4 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 @@ -105,6 +116,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 +158,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 +168,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 } @@ -497,13 +532,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}}) } diff --git a/internal/app/cli/llmapp/statusinfo.go b/internal/app/cli/llmapp/statusinfo.go new file mode 100644 index 0000000..4ae07f1 --- /dev/null +++ b/internal/app/cli/llmapp/statusinfo.go @@ -0,0 +1,110 @@ +// 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). +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")) + 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) + 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/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/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/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/welcome.go b/internal/app/cli/render/block/welcome.go new file mode 100644 index 0000000..b6f904f --- /dev/null +++ b/internal/app/cli/render/block/welcome.go @@ -0,0 +1,202 @@ +// 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) + 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) { + titleMin := width.Width(welcomeTitle) + 5 + 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. From 0762b9a46a62a0a2b223d1614abb197bd52459ff Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 20:51:14 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(render):=20spec-1.25=20D-5/D-6/D-7/D?= =?UTF-8?q?-8=20speaker=20bullet=20+=20=E2=8E=BF=20connector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-5 (CRIT-A): block.Message.Speaker enum;assistant ⏺ bullet 注入在 llmapp drain 边界 (markAssistantBullet 标本 turn 首个 content 节点,per-turn 非 per-line;render/streaming 保持中性 render-only);去 user "> " 前缀 (SpeakerUser plain echo)。bullet glyph 平台分 ⏺/● (figures.ts:4)。 D-6: ToolResult " ⎿ " 连接线 + hanging-indent (content wrap 至 Cols-4 + composite blit at x=4 + ⎿ 落首行 gutter;窄终端 flush-left fallback; GrepTool/UI.tsx:66 parity)。D-7: /report Markdown 前置 SpeakerAssistant header (⏺ 诊断报告)。D-8 (CRIT-C): View offset/large-scrollback 回归 — 真 surface 是 Model.View plain-slice bottom-up loop (非 VirtualScrollback, 从不生产实例化);welcome head 在长 scrollback 被 y>=0 短路 clip。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/bullet_test.go | 62 ++++++++++++++++ internal/app/cli/llmapp/model.go | 40 ++++++++++- internal/app/cli/llmapp/report_cmd.go | 8 +++ internal/app/cli/llmapp/view_offset_test.go | 51 +++++++++++++ internal/app/cli/render/block/message.go | 72 +++++++++++++++++++ .../cli/render/block/message_bullet_test.go | 67 +++++++++++++++++ internal/app/cli/render/block/toolresult.go | 28 +++++++- .../render/block/toolresult_connector_test.go | 70 ++++++++++++++++++ 8 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 internal/app/cli/llmapp/bullet_test.go create mode 100644 internal/app/cli/llmapp/view_offset_test.go create mode 100644 internal/app/cli/render/block/message_bullet_test.go create mode 100644 internal/app/cli/render/block/toolresult_connector_test.go diff --git a/internal/app/cli/llmapp/bullet_test.go b/internal/app/cli/llmapp/bullet_test.go new file mode 100644 index 0000000..752cb37 --- /dev/null +++ b/internal/app/cli/llmapp/bullet_test.go @@ -0,0 +1,62 @@ +// 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) { + 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) { + 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) { + 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 a85bad4..4015e85 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -104,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 @@ -233,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) } } @@ -243,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 @@ -316,7 +325,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) @@ -504,6 +517,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...) } } @@ -599,6 +613,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..6752b0b 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. @@ -53,6 +57,10 @@ func (m *Model) dispatchReport() scheduler.Cmd { } now := time.Now() md := report.Generate(*m.lastSnapshot, 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). + m.scrollback = appendNode(m.scrollback, block.Message{Text: reportHeaderTitle, Speaker: block.SpeakerAssistant}) m.scrollback = appendNode(m.scrollback, block.NewMarkdown(md)) return writeReportCmd(md, now) } 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/render/block/message.go b/internal/app/cli/render/block/message.go index 5c02081..f38b6e8 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,40 @@ 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) { + 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..7586d54 --- /dev/null +++ b/internal/app/cli/render/block/message_bullet_test.go @@ -0,0 +1,67 @@ +// 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) { + // 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) { + 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) { + buf, err := Message{Text: "", Speaker: SpeakerAssistant}.Render(Context{Cols: 40, Rows: 24}) + if err != nil { + t.Fatalf("render err: %v", err) + } + if _, rows := buf.Size(); rows != 0 { + t.Errorf("empty assistant msg rows = %d; want 0", rows) + } +} 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..de0b5e3 --- /dev/null +++ b/internal/app/cli/render/block/toolresult_connector_test.go @@ -0,0 +1,70 @@ +// 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) { + 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_ConnectorNarrowFallback: when cols <= connector width the +// result renders flush-left (no connector) and never panics / overflows. +func TestToolResult_ConnectorNarrowFallback(t *testing.T) { + 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) + } + } +} From 452b9e37085dd423bff69a2e5019e48a409d3a0f Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 21:01:16 +0800 Subject: [PATCH 04/10] =?UTF-8?q?chore(render):=20spec-1.25=20gate=20fixup?= =?UTF-8?q?s=20=E2=80=94=20gosec=20G304=20nolint=20+=20registry=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusinfo.go git-metadata reads 标 #nosec G304(路径源自 cwd discovery walk 非用户输入,同 config/logger 既有 pattern)。git-hooks/spec-registry.txt sync 自 opendbrb SSOT(49 entries,含 1.25=49)。bullet_test.go gofmt。 make gate 全绿(race + lint + gosec + coverage + import-rules + registry-drift)。 Spec: spec-1.25-cc-interaction-parity.md --- git-hooks/spec-registry.txt | 4 ++++ internal/app/cli/llmapp/bullet_test.go | 4 ++-- internal/app/cli/llmapp/statusinfo.go | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) 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 index 752cb37..cc786c7 100644 --- a/internal/app/cli/llmapp/bullet_test.go +++ b/internal/app/cli/llmapp/bullet_test.go @@ -15,8 +15,8 @@ import ( // the bullet is consumed (pending→false) once placed. func TestMarkAssistantBullet_FirstContentOnly(t *testing.T) { nodes := []block.RenderNode{ - block.Message{Empty: true}, // thinking-only placeholder: skip - block.Message{Text: "first answer"}, // ← gets the bullet + 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) diff --git a/internal/app/cli/llmapp/statusinfo.go b/internal/app/cli/llmapp/statusinfo.go index 4ae07f1..cc621d2 100644 --- a/internal/app/cli/llmapp/statusinfo.go +++ b/internal/app/cli/llmapp/statusinfo.go @@ -50,7 +50,7 @@ func GitBranch(dir string) string { if gitDir == "" { return "" } - head, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) + 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 "" } @@ -93,7 +93,7 @@ func resolveGitDir(gitPath string) string { if info.IsDir() { return gitPath } - data, err := os.ReadFile(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 "" } From a6834205f027e7e06926879280bcc9021403c91f Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 21:27:54 +0800 Subject: [PATCH 05/10] fix(render): spec-1.25 T-11 post-impl R-fix (3-route review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三路 post-impl review absorb(claude code-reviewer APPROVE-WITH-FIXES / go-reviewer APPROVE-WITH-FIXES 1H / codex REQUEST-CHANGES 1H): - codex D5-HIGH-1: handleControl 在 append ToolUse 前 drainPendingInto 刷 pending assistant text(carry ⏺ bullet)→ 文本先于工具节点排序。drain 仅 限 ToolUse 分支(ToolResult 分支会拉到下一轮 raced-ahead 文本;Thinking/ Finish drain 破 thinking-only filter timing)+ contentNodesOnly 丢 Empty 占位(turn-end artifact 不入工具边界)。+ 回归测 DrainsTextBeforeToolUse。 - go-reviewer H-1 / code-reviewer MED-1: dispatchReport 改 functional(返 nodes+Cmd,caller append),消 in-place receiver mutation。 - go-reviewer M-3 / code-reviewer MED-2: renderWithBullet Empty 早返回(防 orphan bullet on (no output))+ Empty:true 测。 - codex D4-MED-1: paintStatusLine 段间 dim " · " separator(非尾随空格)。 - codex D1-LOW-1: welcome titleMin+6(top rule 保留尾随 ─)。 - codex D6-LOW-1: ⎿ hanging-indent wrapping 测(续行 col 4 无 connector)。 - M-2: 新测加 t.Parallel;LOW 注释(welcome margin / AbbrevCwd 平台)。 make gate 全绿。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/bullet_test.go | 46 +++++++++++++ internal/app/cli/llmapp/model.go | 69 ++++++++++++++++++- internal/app/cli/llmapp/report_cmd.go | 24 +++---- internal/app/cli/llmapp/statusinfo.go | 2 + internal/app/cli/program/program.go | 16 +++-- internal/app/cli/render/block/message.go | 7 ++ .../cli/render/block/message_bullet_test.go | 21 ++++-- .../render/block/toolresult_connector_test.go | 37 ++++++++++ internal/app/cli/render/block/welcome.go | 8 ++- 9 files changed, 203 insertions(+), 27 deletions(-) diff --git a/internal/app/cli/llmapp/bullet_test.go b/internal/app/cli/llmapp/bullet_test.go index cc786c7..64322f4 100644 --- a/internal/app/cli/llmapp/bullet_test.go +++ b/internal/app/cli/llmapp/bullet_test.go @@ -5,15 +5,59 @@ package llmapp import ( + "context" + "strings" "testing" "github.com/sqlrush/opendbx/internal/app/cli/render/block" + "github.com/sqlrush/opendbx/internal/app/cli/render/streaming" + "github.com/sqlrush/opendbx/internal/domain/llm" + "github.com/sqlrush/opendbx/internal/domain/llm/fake" ) +// TestHandleControl_DrainsTextBeforeToolUse is the regression for codex +// D5-HIGH-1: pending assistant text must be flushed into scrollback (carrying +// the ⏺ bullet) BEFORE a tool-use node is appended, so the prose that +// preceded the tool call orders first — not after the tool tree on a later +// View frame. +func TestHandleControl_DrainsTextBeforeToolUse(t *testing.T) { + t.Parallel() + ts := streaming.NewTokenStream(context.Background()) + ts.Append("checking the slow query\n") // newline-completed → drainable + + m := New(fake.New(), Options{ModelName: "fake"}) + m.stream = ts + m.control = make(chan streamControlMsg, 4) + m.streaming = true + m.assistantBulletPending = true + + mm, _ := m.handleControl(streamControlMsg{ + ToolUse: &llm.ToolUse{ID: "c1", Name: "echo", Input: map[string]any{"q": "x"}}, + }) + sb := mm.(*Model).scrollback + if len(sb) < 2 { + t.Fatalf("expected [assistant text, tooluse], got %d nodes", len(sb)) + } + msg, ok := sb[0].(block.Message) + if !ok { + t.Fatalf("node 0 = %T; want assistant text Message before the tool node", sb[0]) + } + if msg.Speaker != block.SpeakerAssistant { + t.Errorf("flushed assistant text should carry SpeakerAssistant bullet, got %v", msg.Speaker) + } + if !strings.Contains(msg.Text, "checking the slow query") { + t.Errorf("node 0 text = %q; want the pending prose", msg.Text) + } + if _, ok := sb[1].(block.ToolUse); !ok { + t.Errorf("node 1 = %T; want block.ToolUse AFTER the text", sb[1]) + } +} + // 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 @@ -41,6 +85,7 @@ func TestMarkAssistantBullet_FirstContentOnly(t *testing.T) { // 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 { @@ -51,6 +96,7 @@ func TestMarkAssistantBullet_StaysPendingNoContent(t *testing.T) { // 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 { diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index 4015e85..940a602 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -287,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: @@ -378,6 +382,14 @@ 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: flush any pending assistant text + // into scrollback BEFORE the tool-use node, so prose that preceded the + // tool call orders first (carrying the ⏺ bullet) instead of being + // drained later in View and landing after the tool tree. Scoped to the + // node-appending branches only — draining before Thinking/Finish would + // corrupt the thinking-only filter timing (sawThinking/sawContent not + // yet updated; the Finish path drains in streamDoneMsg after Close). + next.scrollback, next.assistantBulletPending = next.drainPendingInto(m.scrollback) // 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 { @@ -386,7 +398,7 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm next.toolUseNames[msg.ToolUse.ID] = msg.ToolUse.Name tu := block.NewToolUse(msg.ToolUse.ID, msg.ToolUse.Name, msg.ToolUse.Input) tu.State = block.StateRunning // caller owns transition per spec-1.9 toolcall.go:62-64 - next.scrollback = appendNode(m.scrollback, tu) + next.scrollback = appendNode(next.scrollback, tu) case msg.ToolResult != nil: name := next.toolUseNames[msg.ToolResult.ToolUseID] if name == "" { @@ -395,6 +407,13 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm // (spec-1.9b R3 HIGH-3). break } + // NB: no drainPendingInto here. The result immediately follows its + // tool-use with no intervening assistant text; and by the time this + // control msg is processed the loop may have raced ahead and streamed + // the NEXT turn's prose into the shared TokenStream — draining it here + // would order that future text before this result. The ToolUse-branch + // drain (which flushes all text preceding a tool call, including a + // prior turn's) is the correct and sufficient ordering fix. // codex T-10a P2-1 absorb: transition the matching block.ToolUse // from StateRunning to StateResolved/StateError so the UI does // not show the tool as "Running" forever once its result has @@ -405,7 +424,7 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm if msg.ToolResult.IsError { targetState = block.StateError } - sb := transitionToolUseState(m.scrollback, msg.ToolResult.ToolUseID, targetState) + sb := transitionToolUseState(next.scrollback, msg.ToolResult.ToolUseID, targetState) tr := block.NewToolResult(msg.ToolResult.ToolUseID, name, msg.ToolResult.Content, msg.ToolResult.IsError) // spec-1.22 D-6: post-set the render-only Cached flag (NewToolResult // signature stays unchanged so the ~30 existing call sites don't move). @@ -621,6 +640,50 @@ func appendNodes(sb []block.RenderNode, nodes []block.RenderNode) []block.Render // 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. +// drainPendingInto flushes any in-flight stream text onto sb (filtering +// thinking-only placeholders + marking the per-turn assistant bullet), +// returning the updated scrollback and pending flag. Used at control +// boundaries (handleControl) so assistant prose that preceded a tool-use / +// finish node is ordered before it (codex D5-HIGH-1). No-op when no stream +// is active or no nodes are pending. Mirrors the View drain (same +// sawThinking/sawContent gating, same single-owner scheduler goroutine). +func (m *Model) drainPendingInto(sb []block.RenderNode) ([]block.RenderNode, bool) { + pending := m.assistantBulletPending + if m.stream == nil { + return sb, pending + } + nodes := m.stream.Drain() + if len(nodes) == 0 { + return sb, pending + } + nodes = filterThinkingOnlyEmpty(nodes, m.sawThinking, m.sawContent) + // Mid-turn boundary: flush only REAL assistant text. Empty / empty-text + // placeholders are turn-end artifacts (the thinking-only "(no output)" + // indicator) and must NOT be inserted before a tool node — the View / + // streamDoneMsg drain still handles them at turn end. + nodes = contentNodesOnly(nodes) + if len(nodes) == 0 { + return sb, pending + } + nodes, pending = markAssistantBullet(nodes, pending) + return appendNodes(sb, nodes), pending +} + +// contentNodesOnly returns a new slice keeping only nodes with real visible +// content — drops block.Message placeholders (Empty or empty Text). Used by +// drainPendingInto so a tool boundary flushes prose but not the turn-end +// "(no output)" placeholder (spec-1.25 D-5 / codex D5-HIGH-1). +func contentNodesOnly(nodes []block.RenderNode) []block.RenderNode { + out := make([]block.RenderNode, 0, len(nodes)) + for _, n := range nodes { + if msg, ok := n.(block.Message); ok && (msg.Empty || msg.Text == "") { + continue + } + out = append(out, n) + } + return out +} + func markAssistantBullet(nodes []block.RenderNode, pending bool) ([]block.RenderNode, bool) { if !pending { return nodes, false diff --git a/internal/app/cli/llmapp/report_cmd.go b/internal/app/cli/llmapp/report_cmd.go index 6752b0b..f1f3029 100644 --- a/internal/app/cli/llmapp/report_cmd.go +++ b/internal/app/cli/llmapp/report_cmd.go @@ -44,25 +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) // 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). - m.scrollback = appendNode(m.scrollback, block.Message{Text: reportHeaderTitle, Speaker: block.SpeakerAssistant}) - m.scrollback = appendNode(m.scrollback, block.NewMarkdown(md)) - return writeReportCmd(md, now) + 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 index cc621d2..c7d773d 100644 --- a/internal/app/cli/llmapp/statusinfo.go +++ b/internal/app/cli/llmapp/statusinfo.go @@ -19,6 +19,8 @@ import ( // 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 "" diff --git a/internal/app/cli/program/program.go b/internal/app/cli/program/program.go index b34b651..2e9ee66 100644 --- a/internal/app/cli/program/program.go +++ b/internal/app/cli/program/program.go @@ -479,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/render/block/message.go b/internal/app/cli/render/block/message.go index f38b6e8..7b2134a 100644 --- a/internal/app/cli/render/block/message.go +++ b/internal/app/cli/render/block/message.go @@ -122,6 +122,13 @@ func (m Message) Render(ctx Context) (buffer.Buffer, error) { // 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 diff --git a/internal/app/cli/render/block/message_bullet_test.go b/internal/app/cli/render/block/message_bullet_test.go index 7586d54..db0e630 100644 --- a/internal/app/cli/render/block/message_bullet_test.go +++ b/internal/app/cli/render/block/message_bullet_test.go @@ -14,6 +14,7 @@ import ( // 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}) @@ -44,6 +45,7 @@ func TestMessage_AssistantBullet(t *testing.T) { // 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}) @@ -57,11 +59,20 @@ func TestMessage_UserAndNonePlain(t *testing.T) { // TestMessage_AssistantBulletEmptyNoOrphan: an empty-text assistant message // renders 0 rows (no orphan bullet on a blank line). func TestMessage_AssistantBulletEmptyNoOrphan(t *testing.T) { - buf, err := Message{Text: "", Speaker: SpeakerAssistant}.Render(Context{Cols: 40, Rows: 24}) - if err != nil { - t.Fatalf("render err: %v", err) + 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}, } - if _, rows := buf.Size(); rows != 0 { - t.Errorf("empty assistant msg rows = %d; want 0", rows) + 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/toolresult_connector_test.go b/internal/app/cli/render/block/toolresult_connector_test.go index de0b5e3..6efff16 100644 --- a/internal/app/cli/render/block/toolresult_connector_test.go +++ b/internal/app/cli/render/block/toolresult_connector_test.go @@ -14,6 +14,7 @@ import ( // 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 { @@ -45,9 +46,45 @@ func TestToolResult_Connector(t *testing.T) { } } +// 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 { diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go index b6f904f..d925810 100644 --- a/internal/app/cli/render/block/welcome.go +++ b/internal/app/cli/render/block/welcome.go @@ -70,6 +70,9 @@ func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { 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 @@ -126,7 +129,10 @@ func (w Welcome) renderFallback(ctx Context) buffer.Buffer { // 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) { - titleMin := width.Width(welcomeTitle) + 5 + // +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 { From 598ec0dc8d85a9b40debf253d67e52ca03d7a4de Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 21:39:33 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(llmapp):=20revert=20racy=20D5-HIGH-1?= =?UTF-8?q?=20drain=20=E2=80=94=20defer=20to=20spec-1.21=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex D5-HIGH-1 的 drain-at-tool-boundary 修法在 shared-TokenStream + async-control 架构下是 racy:loop goroutine 可 race 完整个多轮,boundary drain 会拉到 FUTURE-turn 文本排到工具节点前(CI timing 下 TestModel_ LoopAppendsToolBlocks 失败暴露;本地 darwin 没抓到 = 规则 10 case)。 正确修法需 producer-side ordering(per-turn stream boundary 或 text-via- ordered-channel)= spec-1.21 架构改动,超 1.25 范围 → DEFER + tracked follow-up。⏺ bullet 经 View/streamDoneMsg drain 的 markAssistantBullet 仍 正确 attach。移除 drainPendingInto/contentNodesOnly + 失效回归测。 5x -race 确定性绿。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/bullet_test.go | 43 --------------- internal/app/cli/llmapp/model.go | 73 +++++--------------------- 2 files changed, 12 insertions(+), 104 deletions(-) diff --git a/internal/app/cli/llmapp/bullet_test.go b/internal/app/cli/llmapp/bullet_test.go index 64322f4..f012476 100644 --- a/internal/app/cli/llmapp/bullet_test.go +++ b/internal/app/cli/llmapp/bullet_test.go @@ -5,54 +5,11 @@ package llmapp import ( - "context" - "strings" "testing" "github.com/sqlrush/opendbx/internal/app/cli/render/block" - "github.com/sqlrush/opendbx/internal/app/cli/render/streaming" - "github.com/sqlrush/opendbx/internal/domain/llm" - "github.com/sqlrush/opendbx/internal/domain/llm/fake" ) -// TestHandleControl_DrainsTextBeforeToolUse is the regression for codex -// D5-HIGH-1: pending assistant text must be flushed into scrollback (carrying -// the ⏺ bullet) BEFORE a tool-use node is appended, so the prose that -// preceded the tool call orders first — not after the tool tree on a later -// View frame. -func TestHandleControl_DrainsTextBeforeToolUse(t *testing.T) { - t.Parallel() - ts := streaming.NewTokenStream(context.Background()) - ts.Append("checking the slow query\n") // newline-completed → drainable - - m := New(fake.New(), Options{ModelName: "fake"}) - m.stream = ts - m.control = make(chan streamControlMsg, 4) - m.streaming = true - m.assistantBulletPending = true - - mm, _ := m.handleControl(streamControlMsg{ - ToolUse: &llm.ToolUse{ID: "c1", Name: "echo", Input: map[string]any{"q": "x"}}, - }) - sb := mm.(*Model).scrollback - if len(sb) < 2 { - t.Fatalf("expected [assistant text, tooluse], got %d nodes", len(sb)) - } - msg, ok := sb[0].(block.Message) - if !ok { - t.Fatalf("node 0 = %T; want assistant text Message before the tool node", sb[0]) - } - if msg.Speaker != block.SpeakerAssistant { - t.Errorf("flushed assistant text should carry SpeakerAssistant bullet, got %v", msg.Speaker) - } - if !strings.Contains(msg.Text, "checking the slow query") { - t.Errorf("node 0 text = %q; want the pending prose", msg.Text) - } - if _, ok := sb[1].(block.ToolUse); !ok { - t.Errorf("node 1 = %T; want block.ToolUse AFTER the text", sb[1]) - } -} - // 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. diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index 940a602..a9031d1 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -382,14 +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: flush any pending assistant text - // into scrollback BEFORE the tool-use node, so prose that preceded the - // tool call orders first (carrying the ⏺ bullet) instead of being - // drained later in View and landing after the tool tree. Scoped to the - // node-appending branches only — draining before Thinking/Finish would - // corrupt the thinking-only filter timing (sawThinking/sawContent not - // yet updated; the Finish path drains in streamDoneMsg after Close). - next.scrollback, next.assistantBulletPending = next.drainPendingInto(m.scrollback) + // 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 { @@ -398,7 +400,7 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm next.toolUseNames[msg.ToolUse.ID] = msg.ToolUse.Name tu := block.NewToolUse(msg.ToolUse.ID, msg.ToolUse.Name, msg.ToolUse.Input) tu.State = block.StateRunning // caller owns transition per spec-1.9 toolcall.go:62-64 - next.scrollback = appendNode(next.scrollback, tu) + next.scrollback = appendNode(m.scrollback, tu) case msg.ToolResult != nil: name := next.toolUseNames[msg.ToolResult.ToolUseID] if name == "" { @@ -407,13 +409,6 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm // (spec-1.9b R3 HIGH-3). break } - // NB: no drainPendingInto here. The result immediately follows its - // tool-use with no intervening assistant text; and by the time this - // control msg is processed the loop may have raced ahead and streamed - // the NEXT turn's prose into the shared TokenStream — draining it here - // would order that future text before this result. The ToolUse-branch - // drain (which flushes all text preceding a tool call, including a - // prior turn's) is the correct and sufficient ordering fix. // codex T-10a P2-1 absorb: transition the matching block.ToolUse // from StateRunning to StateResolved/StateError so the UI does // not show the tool as "Running" forever once its result has @@ -424,7 +419,7 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm if msg.ToolResult.IsError { targetState = block.StateError } - sb := transitionToolUseState(next.scrollback, msg.ToolResult.ToolUseID, targetState) + sb := transitionToolUseState(m.scrollback, msg.ToolResult.ToolUseID, targetState) tr := block.NewToolResult(msg.ToolResult.ToolUseID, name, msg.ToolResult.Content, msg.ToolResult.IsError) // spec-1.22 D-6: post-set the render-only Cached flag (NewToolResult // signature stays unchanged so the ~30 existing call sites don't move). @@ -640,50 +635,6 @@ func appendNodes(sb []block.RenderNode, nodes []block.RenderNode) []block.Render // 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. -// drainPendingInto flushes any in-flight stream text onto sb (filtering -// thinking-only placeholders + marking the per-turn assistant bullet), -// returning the updated scrollback and pending flag. Used at control -// boundaries (handleControl) so assistant prose that preceded a tool-use / -// finish node is ordered before it (codex D5-HIGH-1). No-op when no stream -// is active or no nodes are pending. Mirrors the View drain (same -// sawThinking/sawContent gating, same single-owner scheduler goroutine). -func (m *Model) drainPendingInto(sb []block.RenderNode) ([]block.RenderNode, bool) { - pending := m.assistantBulletPending - if m.stream == nil { - return sb, pending - } - nodes := m.stream.Drain() - if len(nodes) == 0 { - return sb, pending - } - nodes = filterThinkingOnlyEmpty(nodes, m.sawThinking, m.sawContent) - // Mid-turn boundary: flush only REAL assistant text. Empty / empty-text - // placeholders are turn-end artifacts (the thinking-only "(no output)" - // indicator) and must NOT be inserted before a tool node — the View / - // streamDoneMsg drain still handles them at turn end. - nodes = contentNodesOnly(nodes) - if len(nodes) == 0 { - return sb, pending - } - nodes, pending = markAssistantBullet(nodes, pending) - return appendNodes(sb, nodes), pending -} - -// contentNodesOnly returns a new slice keeping only nodes with real visible -// content — drops block.Message placeholders (Empty or empty Text). Used by -// drainPendingInto so a tool boundary flushes prose but not the turn-end -// "(no output)" placeholder (spec-1.25 D-5 / codex D5-HIGH-1). -func contentNodesOnly(nodes []block.RenderNode) []block.RenderNode { - out := make([]block.RenderNode, 0, len(nodes)) - for _, n := range nodes { - if msg, ok := n.(block.Message); ok && (msg.Empty || msg.Text == "") { - continue - } - out = append(out, n) - } - return out -} - func markAssistantBullet(nodes []block.RenderNode, pending bool) ([]block.RenderNode, bool) { if !pending { return nodes, false From c12f6459eee1f1825e6258556cde6183256f97b6 Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 22:35:21 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(render):=20spec-1.25=20errata=20R3?= =?UTF-8?q?=20=E2=80=94=20welcome=20ASCII-art=20logo=20(=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20Layer-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 Layer-5 反馈 boxed welcome 与 CC 不像(CC 当前 WelcomeV2 是 ASCII-art logo)→ path 3/3 拍板复刻 logo 形态。welcome.go 从 boxed `╭─ ✻ ─╮` 改为 opendbx 原创 3-row block-mark(▛▀▜/▌▒▐/▙▄▟,非 Claude 品牌 logo,§3.2)左 + 文本右(opendbx vX / ✻ Welcome to opendbx! / cwd: ~/path)+ tip 行;? for shortcuts 移除(输入框 bottom rule 已有)。mechanics 不变(MeasureOnly / graceful 窄退化单行 / width≤cols 不变量 / 全 width-1 glyph)。测改 logo 断言。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/render/block/welcome.go | 185 ++++++------------ internal/app/cli/render/block/welcome_test.go | 31 ++- 2 files changed, 80 insertions(+), 136 deletions(-) diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go index d925810..340fd58 100644 --- a/internal/app/cli/render/block/welcome.go +++ b/internal/app/cli/render/block/welcome.go @@ -2,16 +2,15 @@ // // 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. +// File welcome.go — production Welcome panel block (spec-1.25 D-1 + errata R3). +// Renames + replaces the spec-0.13 Banner stub (grep-confirmed zero +// production callers). Renders an ASCII-art logo welcome whose STRUCTURE +// mirrors CC's WelcomeV2.tsx (logo-left + welcome text-right + cwd + tip); +// the logo GLYPHS are an opendbx-original block-drawing DB mark, NOT Claude's +// brand logo (CLAUDE.md § 3.2: do not copy CC brand assets). `✻` = CC +// figures.ts:6 TEARDROP_ASTERISK. render-only: never enters the llm wire. +// errata R3 (用户 Layer-5 拍板 2026-06-02): replaced the R2 boxed +// `╭─ ✻ Welcome ─╮` approximation with this logo form. package block @@ -21,25 +20,31 @@ import ( "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" +// welcomeTitle is the welcome headline (CC parity "Welcome to Claude Code!" +// → opendbx). `✻` = figures.ts:6 TEARDROP_ASTERISK. +const welcomeTitle = "✻ Welcome to opendbx!" + +// welcomeMark is the opendbx-original 3-row block-drawing DB mark rendered +// at the left of the welcome (an opendbx asset, not Claude's logo). All +// glyphs are width-1 box/block-drawing runes (EastAsianWidth=false). +var welcomeMark = [3]string{ + "▛▀▀▀▀▜", + "▌▒▒▒▒▐", + "▙▄▄▄▄▟", +} -// Box-drawing runes for the rounded welcome frame. const ( - bdTopLeft = '╭' - bdTopRight = '╮' - bdBottomLeft = '╰' - bdBottomRight = '╯' - bdHorizontal = '─' - bdVertical = '│' + welcomeMarkW = 6 // visual width of each welcomeMark row + welcomeGap = 2 // spaces between the mark and the text column ) +// welcomeTextCol is the column where the right-side text begins. +const welcomeTextCol = welcomeMarkW + welcomeGap + // 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). +// spec-0.13 Banner stub). Zero-value Welcome{} renders the logo with no +// version/cwd/tip (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 @@ -47,67 +52,72 @@ type Welcome struct { } // NewWelcome constructs a Welcome panel. Empty fields are simply omitted -// from the body (e.g. version="" drops the version line). +// (e.g. version="" drops the version suffix; tip="" drops the tip 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 <version>` (spec-1.25 D-1 / R-2). MeasureOnly +// Render produces the logo welcome Buffer: 3 logo rows paired with text +// (headline / version / cwd) + a blank + the tip line. Graceful: when Cols +// is too small for the logo+text it falls back to a single line +// `✻ Welcome to opendbx <version>` (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 { + verLine := "opendbx" + if w.Version != "" { + verLine += " " + w.Version + } + cwdLine := "" + if w.Cwd != "" { + cwdLine = "cwd: " + w.Cwd + } + widest := max(width.Width(verLine), width.Width(welcomeTitle), width.Width(cwdLine)) + if ctx.Cols < welcomeTextCol+widest { return w.renderFallback(ctx), nil } - rows := len(body) + 2 // top rule + body + bottom rule + + rows := len(welcomeMark) // 3 logo rows + if w.Tip != "" { + rows += 2 // blank spacer + tip line + } 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 } + theme := themeOrDefault(ctx.Theme) 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) + // Logo art at column 0 (accent/normal). + for i, art := range welcomeMark { + writeRunes(buf, 0, i, art, normal, welcomeMarkW) } - 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 + // Row 0: "opendbx" (normal) + " <version>" (dim). + x := writeRunes(buf, welcomeTextCol, 0, "opendbx", normal, ctx.Cols) if w.Version != "" { - lines = append(lines, w.Version) + writeRunes(buf, x, 0, " "+w.Version, dim, ctx.Cols) } - if w.Cwd != "" { - lines = append(lines, w.Cwd) + // Row 1: "✻ Welcome to opendbx!" (normal). + writeRunes(buf, welcomeTextCol, 1, welcomeTitle, normal, ctx.Cols) + // Row 2: "cwd: <cwd>" (dim). + if cwdLine != "" { + writeRunes(buf, welcomeTextCol, 2, cwdLine, dim, ctx.Cols) } + // Row 4 (after a blank row 3): tip (dim), truncated at Cols. if w.Tip != "" { - lines = append(lines, w.Tip) + writeRunes(buf, welcomeTextCol, 4, w.Tip, dim, ctx.Cols) } - lines = append(lines, "") // blank spacer before the shortcuts hint - lines = append(lines, welcomeShortcuts) - return lines + return buf, nil } // renderFallback returns a single dim line "✻ Welcome to opendbx <version>" -// truncated to Cols, used when the terminal is too narrow for the frame. +// truncated to Cols, used when the terminal is too narrow for the logo. func (w Welcome) renderFallback(ctx Context) buffer.Buffer { if ctx.MeasureOnly { return measureOnlyBuf(ctx.Cols, 1) @@ -124,71 +134,6 @@ func (w Welcome) renderFallback(ctx Context) buffer.Buffer { 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 ╭─ <title> ─...─╮ 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 { diff --git a/internal/app/cli/render/block/welcome_test.go b/internal/app/cli/render/block/welcome_test.go index ee2c24b..80e28d2 100644 --- a/internal/app/cli/render/block/welcome_test.go +++ b/internal/app/cli/render/block/welcome_test.go @@ -38,7 +38,7 @@ func gridRow(t *testing.T, buf buffer.Buffer, y int) string { return strings.TrimRight(b.String(), " ") } -func TestWelcome_BoxedRender(t *testing.T) { +func TestWelcome_LogoRender(t *testing.T) { w := NewWelcome("v0.49.0", "~/opendbx", "Try \"为什么这条 SQL 慢\"") buf, err := w.Render(Context{Cols: 80, Rows: 24}) if err != nil { @@ -46,32 +46,31 @@ func TestWelcome_BoxedRender(t *testing.T) { } g := buf.(*buffer.Grid) cols, rows := g.Size() - if rows < 4 { - t.Fatalf("boxed welcome should have >=4 rows, got %d", rows) + if rows < 3 { + t.Fatalf("logo welcome should have >=3 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) + // row 0 starts with the opendbx logo mark (left column), text follows. + if got := g.Cell(0, 0).Ch; got != []rune(welcomeMark[0])[0] { + t.Errorf("row0 col0 = %q; want logo mark glyph %q", got, []rune(welcomeMark[0])[0]) } - if !strings.HasPrefix(bottom, "╰") || !strings.HasSuffix(bottom, "╯") { - t.Errorf("bottom = %q; want ╰...╯", bottom) - } - // body carries version, cwd, tip, and the shortcuts hint. + // the headline / version / cwd / tip appear in the text column. all := "" for y := 0; y < rows; y++ { all += gridRow(t, buf, y) + "\n" } - for _, want := range []string{"v0.49.0", "~/opendbx", "为什么这条 SQL 慢", "? for shortcuts"} { + for _, want := range []string{"opendbx", "v0.49.0", "✻ Welcome to opendbx!", "cwd: ~/opendbx", "为什么这条 SQL 慢"} { if !strings.Contains(all, want) { - t.Errorf("welcome box missing %q; got:\n%s", want, all) + t.Errorf("welcome logo missing %q; got:\n%s", want, all) } } + // "? for shortcuts" is NOT in the welcome (it lives in the input bottom rule). + if strings.Contains(all, "? for shortcuts") { + t.Errorf("welcome must NOT carry '? for shortcuts' (input footer owns it); got:\n%s", 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) + if rw := width.Width(gridRow(t, buf, y)); rw > cols { + t.Errorf("row %d width %d exceeds cols %d", y, rw, cols) } } } From 6d6ce1e26a1780b776096c365a013ff006d7db57 Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 22:53:58 +0800 Subject: [PATCH 08/10] =?UTF-8?q?feat(render):=20spec-1.25=20R4=20?= =?UTF-8?q?=E2=80=94=20welcome=20clone=20CC=20clawd=20mascot=20(TEST-PHASE?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 path 3/3 拍板:测试阶段 welcome 1:1 照搬 CC WelcomeV2 dark-variant (含 Claude/clawd 吉祥物 ASCII-art),验 render 引擎保真 + CC UX parity。 welcome.go 复刻 CC 58-wide mascot(sunset rays ░ + clawd body █▓▒ + 星 *, verbatim 自 WelcomeV2.tsx t1..t15)+ 标题 Welcome to opendbx vX(orange + dim version)+ cwd/tip footer;per-glyph 上色(body=#D97757,rays=dim)。 Cols<58 graceful 单行。 !!! TEST-PHASE ONLY — 吉祥物是 Anthropic 品牌资产,商业化 1.0 前必须换 opendbx 原创(file 头 + spec §10 + roadmap pre-1.0 gate 挂 tracked 义务;§3.2)。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/render/block/welcome.go | 167 +++++++++++------- internal/app/cli/render/block/welcome_test.go | 24 ++- 2 files changed, 110 insertions(+), 81 deletions(-) diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go index 340fd58..057f819 100644 --- a/internal/app/cli/render/block/welcome.go +++ b/internal/app/cli/render/block/welcome.go @@ -2,15 +2,18 @@ // // Author: sqlrush -// File welcome.go — production Welcome panel block (spec-1.25 D-1 + errata R3). -// Renames + replaces the spec-0.13 Banner stub (grep-confirmed zero -// production callers). Renders an ASCII-art logo welcome whose STRUCTURE -// mirrors CC's WelcomeV2.tsx (logo-left + welcome text-right + cwd + tip); -// the logo GLYPHS are an opendbx-original block-drawing DB mark, NOT Claude's -// brand logo (CLAUDE.md § 3.2: do not copy CC brand assets). `✻` = CC -// figures.ts:6 TEARDROP_ASTERISK. render-only: never enters the llm wire. -// errata R3 (用户 Layer-5 拍板 2026-06-02): replaced the R2 boxed -// `╭─ ✻ Welcome ─╮` approximation with this logo form. +// File welcome.go — production Welcome panel block (spec-1.25 D-1 + errata R4). +// +// !!! TEST-PHASE ONLY — MUST BE REPLACED BEFORE 1.0 COMMERCIALIZATION !!! +// errata R4 (用户 path 3/3 2026-06-02): the user authorized a 1:1 clone of CC's +// WelcomeV2 dark-variant welcome (including the Claude/clawd mascot ASCII-art) +// FOR THE TEST PHASE ONLY, to validate render-engine fidelity + CC UX parity. +// The mascot art below is Anthropic's brand asset; per the user directive it +// MUST be swapped for an opendbx-original logo before commercial 1.0 (tracked: +// spec-1.25 §10 forward-link + roadmap pre-1.0 gate; CLAUDE.md § 3.2). Do NOT +// ship this welcome in a commercial build. +// +// render-only: never enters the llm wire. package block @@ -20,68 +23,68 @@ import ( "github.com/sqlrush/opendbx/internal/app/cli/render/width" ) -// welcomeTitle is the welcome headline (CC parity "Welcome to Claude Code!" -// → opendbx). `✻` = figures.ts:6 TEARDROP_ASTERISK. -const welcomeTitle = "✻ Welcome to opendbx!" +// welcomeWidth is CC's WELCOME_V2_WIDTH (WelcomeV2.tsx:5). Below it the +// welcome degrades to a single text line. +const welcomeWidth = 58 -// welcomeMark is the opendbx-original 3-row block-drawing DB mark rendered -// at the left of the welcome (an opendbx asset, not Claude's logo). All -// glyphs are width-1 box/block-drawing runes (EastAsianWidth=false). -var welcomeMark = [3]string{ - "▛▀▀▀▀▜", - "▌▒▒▒▒▐", - "▙▄▄▄▄▟", -} +// welcomeTitle is the headline (CC dark-variant format: title + dim version). +const welcomeTitle = "Welcome to opendbx" -const ( - welcomeMarkW = 6 // visual width of each welcomeMark row - welcomeGap = 2 // spaces between the mark and the text column -) +// welcomeArt is CC's WelcomeV2 dark-variant mascot (sunset rays `░` + clawd +// body `█▓▒` + sparkles `*`, 58-wide), extracted verbatim from +// claude-code-source-code WelcomeV2.tsx t1..t15. TEST-PHASE clone — see file +// header (MUST replace before 1.0). Per-glyph coloring approximates CC's +// claude/clawd spans (body=accent orange, rays/horizon=dim). +var welcomeArt = []string{ + "…………………………………………………………………………………………………………………………………………………………", + " ", + " * █████▓▓░ ", + " * ███▓░ ░░ ", + " ░░░░░░ ███▓░ ", + " ░░░ ░░░░░░░░░░ ███▓░ ", + " ░░░░░░░░░░░░░░░░░░░ ██▓░░ ▓ ", + " ░▓▓███▓▓░ ", + " * ░░░░ ", + " ░░░░░░░░ ", + " ░░░░░░░░░░░░░░░░ ", + "", + " ", + " ", + " * ", +} -// welcomeTextCol is the column where the right-side text begins. -const welcomeTextCol = welcomeMarkW + welcomeGap +// welcomeAccent is the Claude-brand terracotta orange (~#D97757) used for the +// title + mascot body glyphs (TEST-PHASE clone fidelity). +var welcomeAccent = style.Style{FG: style.RGB(0xD9, 0x77, 0x57)} // Welcome is the spec-1.25 D-1 production welcome panel (replaces the -// spec-0.13 Banner stub). Zero-value Welcome{} renders the logo with no -// version/cwd/tip (it does NOT return ErrUnsupportedNode — that was the -// stub contract). +// spec-0.13 Banner stub). Zero-value Welcome{} renders with no version/cwd. 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 -// (e.g. version="" drops the version suffix; tip="" drops the tip line). +// NewWelcome constructs a Welcome panel. func NewWelcome(version, cwd, tip string) Welcome { return Welcome{Version: version, Cwd: cwd, Tip: tip} } -// Render produces the logo welcome Buffer: 3 logo rows paired with text -// (headline / version / cwd) + a blank + the tip line. Graceful: when Cols -// is too small for the logo+text it falls back to a single line -// `✻ Welcome to opendbx <version>` (spec-1.25 D-1 / R-2). MeasureOnly -// returns the correct row count with no cell writes (spec-1.7 contract). +// Render produces the CC dark-variant welcome: title line, then the clawd +// mascot art, then a dim cwd/tip footer. Graceful: Cols < welcomeWidth → +// single-line `✻ Welcome to opendbx <version>` fallback. MeasureOnly returns +// the row count with no cell writes. func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { if ctx.Cols <= 0 { return measureOnlyBuf(0, 0), nil } - verLine := "opendbx" - if w.Version != "" { - verLine += " " + w.Version - } - cwdLine := "" - if w.Cwd != "" { - cwdLine = "cwd: " + w.Cwd - } - widest := max(width.Width(verLine), width.Width(welcomeTitle), width.Width(cwdLine)) - if ctx.Cols < welcomeTextCol+widest { + if ctx.Cols < welcomeWidth { return w.renderFallback(ctx), nil } - - rows := len(welcomeMark) // 3 logo rows - if w.Tip != "" { - rows += 2 // blank spacer + tip line + footer := w.footerLines() + rows := 1 + len(welcomeArt) // title + mascot + if len(footer) > 0 { + rows += 1 + len(footer) // blank + footer } if ctx.MeasureOnly { return measureOnlyBuf(ctx.Cols, rows), nil @@ -92,37 +95,67 @@ func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { } theme := themeOrDefault(ctx.Theme) dim := theme.Style(StyleDimmed) - normal := theme.Style(StyleNormal) - // Logo art at column 0 (accent/normal). - for i, art := range welcomeMark { - writeRunes(buf, 0, i, art, normal, welcomeMarkW) - } - // Row 0: "opendbx" (normal) + " <version>" (dim). - x := writeRunes(buf, welcomeTextCol, 0, "opendbx", normal, ctx.Cols) + // Title: "Welcome to opendbx" (accent) + " <version>" (dim). + x := writeRunes(buf, 0, 0, welcomeTitle, welcomeAccent, ctx.Cols) if w.Version != "" { writeRunes(buf, x, 0, " "+w.Version, dim, ctx.Cols) } - // Row 1: "✻ Welcome to opendbx!" (normal). - writeRunes(buf, welcomeTextCol, 1, welcomeTitle, normal, ctx.Cols) - // Row 2: "cwd: <cwd>" (dim). - if cwdLine != "" { - writeRunes(buf, welcomeTextCol, 2, cwdLine, dim, ctx.Cols) + // Mascot art, per-glyph colored. + for i, line := range welcomeArt { + writeArtRow(buf, i+1, line, dim, ctx.Cols) } - // Row 4 (after a blank row 3): tip (dim), truncated at Cols. - if w.Tip != "" { - writeRunes(buf, welcomeTextCol, 4, w.Tip, dim, ctx.Cols) + // Footer (cwd / tip), dim, after a blank row. + for i, line := range footer { + writeRunes(buf, 0, 1+len(welcomeArt)+1+i, line, dim, ctx.Cols) } return buf, nil } +// footerLines builds the optional dim cwd/tip lines shown under the mascot. +func (w Welcome) footerLines() []string { + var out []string + if w.Cwd != "" { + out = append(out, "cwd: "+w.Cwd) + } + if w.Tip != "" { + out = append(out, w.Tip) + } + return out +} + +// writeArtRow paints one mascot row with per-glyph coloring: body glyphs +// (█▓▒) + sparkle (*) use the accent; rays/horizon (░ …) use dim; spaces are +// blank. Stops at Cols. +func writeArtRow(buf *buffer.Grid, y int, line string, dim style.Style, cols int) { + x := 0 + for _, r := range line { + rw := width.RuneWidth(r) + if rw <= 0 { + continue + } + if x+rw > cols { + break + } + switch r { + case ' ': + // leave blank + case '█', '▓', '▒', '*': + buf.SetCell(x, y, buffer.Cell{Ch: r, St: welcomeAccent}) + default: // '░', '…' and any other → dim + buf.SetCell(x, y, buffer.Cell{Ch: r, St: dim}) + } + x += rw + } +} + // renderFallback returns a single dim line "✻ Welcome to opendbx <version>" -// truncated to Cols, used when the terminal is too narrow for the logo. +// when the terminal is too narrow for the 58-wide mascot. func (w Welcome) renderFallback(ctx Context) buffer.Buffer { if ctx.MeasureOnly { return measureOnlyBuf(ctx.Cols, 1) } - line := welcomeTitle + line := "✻ " + welcomeTitle if w.Version != "" { line += " " + w.Version } diff --git a/internal/app/cli/render/block/welcome_test.go b/internal/app/cli/render/block/welcome_test.go index 80e28d2..e76e197 100644 --- a/internal/app/cli/render/block/welcome_test.go +++ b/internal/app/cli/render/block/welcome_test.go @@ -38,34 +38,30 @@ func gridRow(t *testing.T, buf buffer.Buffer, y int) string { return strings.TrimRight(b.String(), " ") } -func TestWelcome_LogoRender(t *testing.T) { +func TestWelcome_CCMascotRender(t *testing.T) { w := NewWelcome("v0.49.0", "~/opendbx", "Try \"为什么这条 SQL 慢\"") - buf, err := w.Render(Context{Cols: 80, Rows: 24}) + buf, err := w.Render(Context{Cols: 80, Rows: 30}) if err != nil { t.Fatalf("Render err: %v", err) } g := buf.(*buffer.Grid) cols, rows := g.Size() - if rows < 3 { - t.Fatalf("logo welcome should have >=3 rows, got %d", rows) + if rows < len(welcomeArt) { + t.Fatalf("mascot welcome should have >= %d rows, got %d", len(welcomeArt), rows) } - // row 0 starts with the opendbx logo mark (left column), text follows. - if got := g.Cell(0, 0).Ch; got != []rune(welcomeMark[0])[0] { - t.Errorf("row0 col0 = %q; want logo mark glyph %q", got, []rune(welcomeMark[0])[0]) - } - // the headline / version / cwd / tip appear in the text column. all := "" for y := 0; y < rows; y++ { all += gridRow(t, buf, y) + "\n" } - for _, want := range []string{"opendbx", "v0.49.0", "✻ Welcome to opendbx!", "cwd: ~/opendbx", "为什么这条 SQL 慢"} { + // title + version + footer (cwd / tip) present + for _, want := range []string{"Welcome to opendbx", "v0.49.0", "cwd: ~/opendbx", "为什么这条 SQL 慢"} { if !strings.Contains(all, want) { - t.Errorf("welcome logo missing %q; got:\n%s", want, all) + t.Errorf("welcome missing %q; got:\n%s", want, all) } } - // "? for shortcuts" is NOT in the welcome (it lives in the input bottom rule). - if strings.Contains(all, "? for shortcuts") { - t.Errorf("welcome must NOT carry '? for shortcuts' (input footer owns it); got:\n%s", all) + // the clawd mascot body glyphs appear (CC-clone fidelity) + if !strings.ContainsRune(all, '█') || !strings.ContainsRune(all, '░') { + t.Errorf("welcome missing mascot glyphs █/░; got:\n%s", all) } // no row exceeds Cols (rule 20 Layer 1 invariant) for y := 0; y < rows; y++ { From d2f68f37c2d7837a78658b21bfc2da10e405c978 Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 23:13:50 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat(render):=20spec-1.25=20R4=20?= =?UTF-8?q?=E2=80=94=20welcome=202-panel=20box=20(CC=20v2.1.x=20clone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户给了 CC v2.1.160 真实 welcome(双栏圆角框)→ 复刻该结构(我之前逆向的 WelcomeV2.tsx 是旧组件,错了)。welcome.go 重写为双栏框:标题嵌顶边框 (opendbx vX,orange)/ 左栏居中(Welcome to opendbx! + ▗ ▗ ▖ ▖ / ▘▘ ▝▝ 星点吉祥物 + model + cwd)/ 右栏(Tips for getting started + tip + divider + What's new + 命令提示)。Cols<90 graceful 单行。+ Welcome.Model 字段,seed 透传 opts.ModelName。 TEST-PHASE ONLY — 布局+星点 mirror CC,1.0 前必换 opendbx 原创(file 头 + spec §10 + roadmap gate)。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/model.go | 6 +- internal/app/cli/render/block/welcome.go | 234 +++++++++++------- internal/app/cli/render/block/welcome_test.go | 32 ++- 3 files changed, 166 insertions(+), 106 deletions(-) diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index a9031d1..bfcd1d6 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -184,9 +184,9 @@ func New(provider llm.Provider, opts Options) *Model { // 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), - } + wel := block.NewWelcome(opts.Version, opts.Cwd, welcomeTip) + wel.Model = opts.ModelName + m.scrollback = []block.RenderNode{wel} } return m } diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go index 057f819..f0433db 100644 --- a/internal/app/cli/render/block/welcome.go +++ b/internal/app/cli/render/block/welcome.go @@ -5,64 +5,52 @@ // File welcome.go — production Welcome panel block (spec-1.25 D-1 + errata R4). // // !!! TEST-PHASE ONLY — MUST BE REPLACED BEFORE 1.0 COMMERCIALIZATION !!! -// errata R4 (用户 path 3/3 2026-06-02): the user authorized a 1:1 clone of CC's -// WelcomeV2 dark-variant welcome (including the Claude/clawd mascot ASCII-art) -// FOR THE TEST PHASE ONLY, to validate render-engine fidelity + CC UX parity. -// The mascot art below is Anthropic's brand asset; per the user directive it -// MUST be swapped for an opendbx-original logo before commercial 1.0 (tracked: -// spec-1.25 §10 forward-link + roadmap pre-1.0 gate; CLAUDE.md § 3.2). Do NOT -// ship this welcome in a commercial build. +// errata R4 (用户 path 3/3 2026-06-02): replicates Claude Code v2.1.x's +// two-panel rounded-box welcome (left: greeting + sparkle mascot + model + +// cwd; right: tips / what's new) for the TEST PHASE, to validate render-engine +// fidelity + CC UX parity. The sparkle mascot + layout mirror Anthropic's CC; +// per the user directive they MUST be swapped for an opendbx-original welcome +// before commercial 1.0 (tracked: spec-1.25 §10 + roadmap pre-1.0 gate; +// CLAUDE.md § 3.2). Do NOT ship this welcome in a commercial build. // // render-only: never enters the llm wire. package block import ( + "strings" + "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" ) -// welcomeWidth is CC's WELCOME_V2_WIDTH (WelcomeV2.tsx:5). Below it the -// welcome degrades to a single text line. -const welcomeWidth = 58 - -// welcomeTitle is the headline (CC dark-variant format: title + dim version). -const welcomeTitle = "Welcome to opendbx" - -// welcomeArt is CC's WelcomeV2 dark-variant mascot (sunset rays `░` + clawd -// body `█▓▒` + sparkles `*`, 58-wide), extracted verbatim from -// claude-code-source-code WelcomeV2.tsx t1..t15. TEST-PHASE clone — see file -// header (MUST replace before 1.0). Per-glyph coloring approximates CC's -// claude/clawd spans (body=accent orange, rays/horizon=dim). -var welcomeArt = []string{ - "…………………………………………………………………………………………………………………………………………………………", - " ", - " * █████▓▓░ ", - " * ███▓░ ░░ ", - " ░░░░░░ ███▓░ ", - " ░░░ ░░░░░░░░░░ ███▓░ ", - " ░░░░░░░░░░░░░░░░░░░ ██▓░░ ▓ ", - " ░▓▓███▓▓░ ", - " * ░░░░ ", - " ░░░░░░░░ ", - " ░░░░░░░░░░░░░░░░ ", - "", - " ", - " ", - " * ", +const ( + // welcomeLeftW is the left-panel inner width (CC v2.1.x uses 52). + welcomeLeftW = 52 + // welcomeMinCols is the minimum terminal width for the two-panel box; + // below it the welcome degrades to a single text line. + welcomeMinCols = 90 +) + +// welcomeMascot is CC's minimal sparkle mark (quadrant block runes), centered +// in the left panel. TEST-PHASE clone — see file header. +var welcomeMascot = []string{ + "▗ ▗ ▖ ▖", + "▘▘ ▝▝", } -// welcomeAccent is the Claude-brand terracotta orange (~#D97757) used for the -// title + mascot body glyphs (TEST-PHASE clone fidelity). +// welcomeAccent is the Claude-brand terracotta orange (~#D97757), used for the +// title + sparkle mascot (TEST-PHASE clone fidelity). var welcomeAccent = style.Style{FG: style.RGB(0xD9, 0x77, 0x57)} // Welcome is the spec-1.25 D-1 production welcome panel (replaces the -// spec-0.13 Banner stub). Zero-value Welcome{} renders with no version/cwd. +// spec-0.13 Banner stub). Zero-value renders with empty fields. 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) + Model string // active model name (left-panel line) } // NewWelcome constructs a Welcome panel. @@ -70,22 +58,20 @@ func NewWelcome(version, cwd, tip string) Welcome { return Welcome{Version: version, Cwd: cwd, Tip: tip} } -// Render produces the CC dark-variant welcome: title line, then the clawd -// mascot art, then a dim cwd/tip footer. Graceful: Cols < welcomeWidth → -// single-line `✻ Welcome to opendbx <version>` fallback. MeasureOnly returns -// the row count with no cell writes. +// Render produces the CC v2.1.x two-panel rounded-box welcome. Graceful: +// Cols < welcomeMinCols → single-line `✻ Welcome to opendbx <version>`. +// MeasureOnly returns the row count with no cell writes. func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { if ctx.Cols <= 0 { return measureOnlyBuf(0, 0), nil } - if ctx.Cols < welcomeWidth { + if ctx.Cols < welcomeMinCols { return w.renderFallback(ctx), nil } - footer := w.footerLines() - rows := 1 + len(welcomeArt) // title + mascot - if len(footer) > 0 { - rows += 1 + len(footer) // blank + footer - } + left := w.leftPanel() + right := w.rightPanel() + n := max(len(left), len(right)) + rows := n + 2 // top + content + bottom if ctx.MeasureOnly { return measureOnlyBuf(ctx.Cols, rows), nil } @@ -95,67 +81,131 @@ func (w Welcome) Render(ctx Context) (buffer.Buffer, error) { } theme := themeOrDefault(ctx.Theme) dim := theme.Style(StyleDimmed) + cols := ctx.Cols - // Title: "Welcome to opendbx" (accent) + " <version>" (dim). - x := writeRunes(buf, 0, 0, welcomeTitle, welcomeAccent, ctx.Cols) - if w.Version != "" { - writeRunes(buf, x, 0, " "+w.Version, dim, ctx.Cols) - } - // Mascot art, per-glyph colored. - for i, line := range welcomeArt { - writeArtRow(buf, i+1, line, dim, ctx.Cols) - } - // Footer (cwd / tip), dim, after a blank row. - for i, line := range footer { - writeRunes(buf, 0, 1+len(welcomeArt)+1+i, line, dim, ctx.Cols) + w.paintTop(buf, cols, dim) + for i := 0; i < n; i++ { + y := i + 1 + buf.SetCell(0, y, buffer.Cell{Ch: '│', St: dim}) + buf.SetCell(welcomeLeftW+1, y, buffer.Cell{Ch: '│', St: dim}) + buf.SetCell(cols-1, y, buffer.Cell{Ch: '│', St: dim}) + if i < len(left) { + lx := 1 + center(left[i].text, welcomeLeftW) + writeRunes(buf, lx, y, left[i].text, left[i].st, welcomeLeftW+1) + } + if i < len(right) { + writeRunes(buf, welcomeLeftW+3, y, right[i].text, right[i].st, cols-1) + } } + w.paintBottom(buf, rows-1, cols, dim) return buf, nil } -// footerLines builds the optional dim cwd/tip lines shown under the mascot. -func (w Welcome) footerLines() []string { - var out []string - if w.Cwd != "" { - out = append(out, "cwd: "+w.Cwd) +// styledLine is a panel line with its style. +type styledLine struct { + text string + st style.Style +} + +// leftPanel builds the centered left-panel lines (greeting + mascot + model +// + cwd), styled. +func (w Welcome) leftPanel() []styledLine { + dim := DefaultTheme{}.Style(StyleDimmed) + normal := DefaultTheme{}.Style(StyleNormal) + out := []styledLine{ + {"", normal}, + {"Welcome to opendbx!", normal}, + {"", normal}, + {welcomeMascot[0], welcomeAccent}, + {"", normal}, + {welcomeMascot[1], welcomeAccent}, } - if w.Tip != "" { - out = append(out, w.Tip) + if w.Model != "" { + out = append(out, styledLine{w.Model, dim}) + } else { + out = append(out, styledLine{"", normal}) + } + out = append(out, styledLine{"", normal}) + if w.Cwd != "" { + out = append(out, styledLine{w.Cwd, dim}) } return out } -// writeArtRow paints one mascot row with per-glyph coloring: body glyphs -// (█▓▒) + sparkle (*) use the accent; rays/horizon (░ …) use dim; spaces are -// blank. Stops at Cols. -func writeArtRow(buf *buffer.Grid, y int, line string, dim style.Style, cols int) { - x := 0 - for _, r := range line { - rw := width.RuneWidth(r) - if rw <= 0 { - continue - } - if x+rw > cols { - break - } - switch r { - case ' ': - // leave blank - case '█', '▓', '▒', '*': - buf.SetCell(x, y, buffer.Cell{Ch: r, St: welcomeAccent}) - default: // '░', '…' and any other → dim - buf.SetCell(x, y, buffer.Cell{Ch: r, St: dim}) - } - x += rw +// rightPanel builds the left-aligned right-panel lines (tips / what's new), +// styled. +func (w Welcome) rightPanel() []styledLine { + dim := DefaultTheme{}.Style(StyleDimmed) + normal := DefaultTheme{}.Style(StyleNormal) + tip := w.Tip + if tip == "" { + tip = "直接用自然语言描述你的数据库问题即可开始诊断" + } + return []styledLine{ + {"", normal}, + {"Tips for getting started", normal}, + {tip, dim}, + {strings.Repeat("─", 40), dim}, + {"What's new", normal}, + {"interact TUI 对齐 Claude Code(spec-1.25)", dim}, + {"welcome / 输入框 / 状态行 / ⏺ 消息 / ⎿ 工具树", dim}, + {"", normal}, + {"自然语言诊断 · \\ 执行 SQL · / 命令(Stage 2)", dim}, + } +} + +// paintTop writes ╭─── opendbx <version> ───…───╮ across cols. +func (w Welcome) paintTop(buf *buffer.Grid, cols int, dim style.Style) { + title := "opendbx" + if w.Version != "" { + title += " " + w.Version + } + buf.SetCell(0, 0, buffer.Cell{Ch: '╭', St: dim}) + x := 1 + for ; x < 4 && x < cols-1; x++ { + buf.SetCell(x, 0, buffer.Cell{Ch: '─', St: dim}) + } + if x < cols-1 { + buf.SetCell(x, 0, buffer.Cell{Ch: ' ', St: dim}) + x++ + } + x = writeRunes(buf, x, 0, title, welcomeAccent, cols-1) + if x < cols-1 { + buf.SetCell(x, 0, buffer.Cell{Ch: ' ', St: dim}) + x++ + } + for ; x < cols-1; x++ { + buf.SetCell(x, 0, buffer.Cell{Ch: '─', St: dim}) + } + buf.SetCell(cols-1, 0, buffer.Cell{Ch: '╮', St: dim}) +} + +// paintBottom writes ╰───…───╯ at row y. +func (w Welcome) paintBottom(buf *buffer.Grid, y, cols int, dim style.Style) { + buf.SetCell(0, y, buffer.Cell{Ch: '╰', St: dim}) + for x := 1; x < cols-1; x++ { + buf.SetCell(x, y, buffer.Cell{Ch: '─', St: dim}) + } + buf.SetCell(cols-1, y, buffer.Cell{Ch: '╯', St: dim}) +} + +// center returns the left-pad (in cells) to center text of display width +// within w columns. +func center(text string, w int) int { + tw := width.Width(text) + if tw >= w { + return 0 } + return (w - tw) / 2 } -// renderFallback returns a single dim line "✻ Welcome to opendbx <version>" -// when the terminal is too narrow for the 58-wide mascot. +// renderFallback returns a single dim line when the terminal is too narrow +// for the two-panel box. func (w Welcome) renderFallback(ctx Context) buffer.Buffer { if ctx.MeasureOnly { return measureOnlyBuf(ctx.Cols, 1) } - line := "✻ " + welcomeTitle + line := "✻ Welcome to opendbx" if w.Version != "" { line += " " + w.Version } diff --git a/internal/app/cli/render/block/welcome_test.go b/internal/app/cli/render/block/welcome_test.go index e76e197..ce6f354 100644 --- a/internal/app/cli/render/block/welcome_test.go +++ b/internal/app/cli/render/block/welcome_test.go @@ -39,29 +39,39 @@ func gridRow(t *testing.T, buf buffer.Buffer, y int) string { } func TestWelcome_CCMascotRender(t *testing.T) { - w := NewWelcome("v0.49.0", "~/opendbx", "Try \"为什么这条 SQL 慢\"") - buf, err := w.Render(Context{Cols: 80, Rows: 30}) + w := NewWelcome("v0.49.0", "~/opendbx", "为什么这条 SQL 慢") + w.Model = "deepseek-v4-pro" + buf, err := w.Render(Context{Cols: 120, Rows: 30}) if err != nil { t.Fatalf("Render err: %v", err) } g := buf.(*buffer.Grid) cols, rows := g.Size() - if rows < len(welcomeArt) { - t.Fatalf("mascot welcome should have >= %d rows, got %d", len(welcomeArt), rows) + if rows < 6 { + t.Fatalf("two-panel welcome should have several rows, got %d", rows) } all := "" for y := 0; y < rows; y++ { all += gridRow(t, buf, y) + "\n" } - // title + version + footer (cwd / tip) present - for _, want := range []string{"Welcome to opendbx", "v0.49.0", "cwd: ~/opendbx", "为什么这条 SQL 慢"} { + // rounded box corners + two-panel content + if g.Cell(0, 0).Ch != '╭' || g.Cell(cols-1, 0).Ch != '╮' { + t.Errorf("top corners = %q/%q; want ╭/╮", g.Cell(0, 0).Ch, g.Cell(cols-1, 0).Ch) + } + for _, want := range []string{ + "opendbx", "v0.49.0", // title + "Welcome to opendbx!", // left greeting + "deepseek-v4-pro", "~/opendbx", // left model + cwd + "Tips for getting started", // right header + "为什么这条 SQL 慢", // right tip + } { if !strings.Contains(all, want) { t.Errorf("welcome missing %q; got:\n%s", want, all) } } - // the clawd mascot body glyphs appear (CC-clone fidelity) - if !strings.ContainsRune(all, '█') || !strings.ContainsRune(all, '░') { - t.Errorf("welcome missing mascot glyphs █/░; got:\n%s", all) + // the sparkle mascot quadrant glyphs appear + if !strings.ContainsRune(all, '▗') || !strings.ContainsRune(all, '▘') { + t.Errorf("welcome missing sparkle mascot ▗/▘; got:\n%s", all) } // no row exceeds Cols (rule 20 Layer 1 invariant) for y := 0; y < rows; y++ { @@ -94,9 +104,9 @@ func TestWelcome_NarrowFallbackSingleLine(t *testing.T) { func TestWelcome_MeasureOnly(t *testing.T) { w := NewWelcome("v0.49.0", "~/opendbx", "tip") - full, _ := w.Render(Context{Cols: 80, Rows: 24}) + full, _ := w.Render(Context{Cols: 120, Rows: 30}) _, wantRows := full.(*buffer.Grid).Size() - mo, err := w.Render(Context{Cols: 80, Rows: 24, MeasureOnly: true}) + mo, err := w.Render(Context{Cols: 120, Rows: 30, MeasureOnly: true}) if err != nil { t.Fatalf("MeasureOnly err: %v", err) } From 4a13934bd2332187e5dd295d0544dd693e5f9af0 Mon Sep 17 00:00:00 2001 From: sqlrush <sqlrush@sqlrushdeMacBook-Pro.local> Date: Tue, 2 Jun 2026 23:26:29 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(render):=20spec-1.25=20welcome=20?= =?UTF-8?q?=E2=86=92=20boxed=20(revert=20R3/R4=20logo,=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8B=8D=E6=9D=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户决定不再纠结 CC-identical welcome,回退到最初干净的 boxed welcome (╭─ ✻ Welcome to opendbx ─╮ + 版本/cwd/tip/? for shortcuts,含 codex D1-LOW-1 titleMin+6 fix),继续往下收口。撤销 errata R3(block-mark logo) + R4(CC clawd mascot / 双栏框):CC-identical welcome 作为 future polish 推迟 (boxed 是 opendbx 原创,无 1.0 品牌替换义务)。 Spec: spec-1.25-cc-interaction-parity.md --- internal/app/cli/llmapp/model.go | 6 +- internal/app/cli/render/block/welcome.go | 276 ++++++++---------- internal/app/cli/render/block/welcome_test.go | 47 ++- 3 files changed, 148 insertions(+), 181 deletions(-) diff --git a/internal/app/cli/llmapp/model.go b/internal/app/cli/llmapp/model.go index bfcd1d6..a9031d1 100644 --- a/internal/app/cli/llmapp/model.go +++ b/internal/app/cli/llmapp/model.go @@ -184,9 +184,9 @@ func New(provider llm.Provider, opts Options) *Model { // plain render-only node that scrolls with history and is NOT re-seeded // on /clear (Q-life ★A CC parity). if opts.Welcome { - wel := block.NewWelcome(opts.Version, opts.Cwd, welcomeTip) - wel.Model = opts.ModelName - m.scrollback = []block.RenderNode{wel} + m.scrollback = []block.RenderNode{ + block.NewWelcome(opts.Version, opts.Cwd, welcomeTip), + } } return m } diff --git a/internal/app/cli/render/block/welcome.go b/internal/app/cli/render/block/welcome.go index f0433db..d925810 100644 --- a/internal/app/cli/render/block/welcome.go +++ b/internal/app/cli/render/block/welcome.go @@ -2,219 +2,191 @@ // // Author: sqlrush -// File welcome.go — production Welcome panel block (spec-1.25 D-1 + errata R4). -// -// !!! TEST-PHASE ONLY — MUST BE REPLACED BEFORE 1.0 COMMERCIALIZATION !!! -// errata R4 (用户 path 3/3 2026-06-02): replicates Claude Code v2.1.x's -// two-panel rounded-box welcome (left: greeting + sparkle mascot + model + -// cwd; right: tips / what's new) for the TEST PHASE, to validate render-engine -// fidelity + CC UX parity. The sparkle mascot + layout mirror Anthropic's CC; -// per the user directive they MUST be swapped for an opendbx-original welcome -// before commercial 1.0 (tracked: spec-1.25 §10 + roadmap pre-1.0 gate; -// CLAUDE.md § 3.2). Do NOT ship this welcome in a commercial build. -// -// render-only: never enters the llm wire. +// 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 ( - "strings" - "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" ) -const ( - // welcomeLeftW is the left-panel inner width (CC v2.1.x uses 52). - welcomeLeftW = 52 - // welcomeMinCols is the minimum terminal width for the two-panel box; - // below it the welcome degrades to a single text line. - welcomeMinCols = 90 -) +// welcomeTitle is the boxed-welcome title text (embedded in the top rule). +const welcomeTitle = "✻ Welcome to opendbx" -// welcomeMascot is CC's minimal sparkle mark (quadrant block runes), centered -// in the left panel. TEST-PHASE clone — see file header. -var welcomeMascot = []string{ - "▗ ▗ ▖ ▖", - "▘▘ ▝▝", -} +// welcomeShortcuts mirrors CC's "? for shortcuts" footer hint. +const welcomeShortcuts = "? for shortcuts" -// welcomeAccent is the Claude-brand terracotta orange (~#D97757), used for the -// title + sparkle mascot (TEST-PHASE clone fidelity). -var welcomeAccent = style.Style{FG: style.RGB(0xD9, 0x77, 0x57)} +// 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 renders with empty fields. +// 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) - Model string // active model name (left-panel line) } -// NewWelcome constructs a Welcome panel. +// 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 CC v2.1.x two-panel rounded-box welcome. Graceful: -// Cols < welcomeMinCols → single-line `✻ Welcome to opendbx <version>`. -// MeasureOnly returns the row count with no cell writes. +// 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 <version>` (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 } - if ctx.Cols < welcomeMinCols { + body := w.bodyLines() + boxW, ok := boxWidth(body, ctx.Cols) + if !ok { return w.renderFallback(ctx), nil } - left := w.leftPanel() - right := w.rightPanel() - n := max(len(left), len(right)) - rows := n + 2 // top + content + bottom + 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 } - theme := themeOrDefault(ctx.Theme) dim := theme.Style(StyleDimmed) - cols := ctx.Cols - - w.paintTop(buf, cols, dim) - for i := 0; i < n; i++ { - y := i + 1 - buf.SetCell(0, y, buffer.Cell{Ch: '│', St: dim}) - buf.SetCell(welcomeLeftW+1, y, buffer.Cell{Ch: '│', St: dim}) - buf.SetCell(cols-1, y, buffer.Cell{Ch: '│', St: dim}) - if i < len(left) { - lx := 1 + center(left[i].text, welcomeLeftW) - writeRunes(buf, lx, y, left[i].text, left[i].st, welcomeLeftW+1) - } - if i < len(right) { - writeRunes(buf, welcomeLeftW+3, y, right[i].text, right[i].st, cols-1) - } + 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) } - w.paintBottom(buf, rows-1, cols, dim) + paintWelcomeBottom(buf, rows-1, boxW, dim) return buf, nil } -// styledLine is a panel line with its style. -type styledLine struct { - text string - st style.Style -} - -// leftPanel builds the centered left-panel lines (greeting + mascot + model -// + cwd), styled. -func (w Welcome) leftPanel() []styledLine { - dim := DefaultTheme{}.Style(StyleDimmed) - normal := DefaultTheme{}.Style(StyleNormal) - out := []styledLine{ - {"", normal}, - {"Welcome to opendbx!", normal}, - {"", normal}, - {welcomeMascot[0], welcomeAccent}, - {"", normal}, - {welcomeMascot[1], welcomeAccent}, - } - if w.Model != "" { - out = append(out, styledLine{w.Model, dim}) - } else { - out = append(out, styledLine{"", normal}) - } - out = append(out, styledLine{"", normal}) +// 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 != "" { - out = append(out, styledLine{w.Cwd, dim}) + lines = append(lines, w.Cwd) } - return out -} - -// rightPanel builds the left-aligned right-panel lines (tips / what's new), -// styled. -func (w Welcome) rightPanel() []styledLine { - dim := DefaultTheme{}.Style(StyleDimmed) - normal := DefaultTheme{}.Style(StyleNormal) - tip := w.Tip - if tip == "" { - tip = "直接用自然语言描述你的数据库问题即可开始诊断" - } - return []styledLine{ - {"", normal}, - {"Tips for getting started", normal}, - {tip, dim}, - {strings.Repeat("─", 40), dim}, - {"What's new", normal}, - {"interact TUI 对齐 Claude Code(spec-1.25)", dim}, - {"welcome / 输入框 / 状态行 / ⏺ 消息 / ⎿ 工具树", dim}, - {"", normal}, - {"自然语言诊断 · \\ 执行 SQL · / 命令(Stage 2)", dim}, + if w.Tip != "" { + lines = append(lines, w.Tip) } + lines = append(lines, "") // blank spacer before the shortcuts hint + lines = append(lines, welcomeShortcuts) + return lines } -// paintTop writes ╭─── opendbx <version> ───…───╮ across cols. -func (w Welcome) paintTop(buf *buffer.Grid, cols int, dim style.Style) { - title := "opendbx" +// renderFallback returns a single dim line "✻ Welcome to opendbx <version>" +// 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 != "" { - title += " " + w.Version + line += " " + w.Version + } + buf, err := buffer.NewGrid(ctx.Cols, 1) + if err != nil { + return measureOnlyBuf(ctx.Cols, 1) } - buf.SetCell(0, 0, buffer.Cell{Ch: '╭', St: dim}) - x := 1 - for ; x < 4 && x < cols-1; x++ { - buf.SetCell(x, 0, buffer.Cell{Ch: '─', St: dim}) + 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 + } } - if x < cols-1 { - buf.SetCell(x, 0, buffer.Cell{Ch: ' ', St: dim}) - x++ + want := titleMin + if bodyMin > want { + want = bodyMin } - x = writeRunes(buf, x, 0, title, welcomeAccent, cols-1) - if x < cols-1 { - buf.SetCell(x, 0, buffer.Cell{Ch: ' ', St: dim}) - x++ + if cols < titleMin { + return 0, false // too narrow even for the title frame } - for ; x < cols-1; x++ { - buf.SetCell(x, 0, buffer.Cell{Ch: '─', St: dim}) + if want > cols { + want = cols // cap; body content truncates via writeRunes } - buf.SetCell(cols-1, 0, buffer.Cell{Ch: '╮', St: dim}) + return want, true } -// paintBottom writes ╰───…───╯ at row y. -func (w Welcome) paintBottom(buf *buffer.Grid, y, cols int, dim style.Style) { - buf.SetCell(0, y, buffer.Cell{Ch: '╰', St: dim}) - for x := 1; x < cols-1; x++ { - buf.SetCell(x, y, buffer.Cell{Ch: '─', St: dim}) +// paintWelcomeTop writes ╭─ <title> ─...─╮ 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++ } - buf.SetCell(cols-1, y, buffer.Cell{Ch: '╯', St: dim}) + 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}) } -// center returns the left-pad (in cells) to center text of display width -// within w columns. -func center(text string, w int) int { - tw := width.Width(text) - if tw >= w { - return 0 +// 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}) } - return (w - tw) / 2 + buf.SetCell(boxW-1, y, buffer.Cell{Ch: bdVertical, St: dim}) } -// renderFallback returns a single dim line when the terminal is too narrow -// for the two-panel box. -func (w Welcome) renderFallback(ctx Context) buffer.Buffer { - if ctx.MeasureOnly { - return measureOnlyBuf(ctx.Cols, 1) - } - line := "✻ Welcome to opendbx" - if w.Version != "" { - line += " " + w.Version +// 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, 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 + buf.SetCell(boxW-1, y, buffer.Cell{Ch: bdBottomRight, St: dim}) } // writeRunes writes text into buf at (x0,y) with style s, stopping before diff --git a/internal/app/cli/render/block/welcome_test.go b/internal/app/cli/render/block/welcome_test.go index ce6f354..ee2c24b 100644 --- a/internal/app/cli/render/block/welcome_test.go +++ b/internal/app/cli/render/block/welcome_test.go @@ -38,45 +38,40 @@ func gridRow(t *testing.T, buf buffer.Buffer, y int) string { return strings.TrimRight(b.String(), " ") } -func TestWelcome_CCMascotRender(t *testing.T) { - w := NewWelcome("v0.49.0", "~/opendbx", "为什么这条 SQL 慢") - w.Model = "deepseek-v4-pro" - buf, err := w.Render(Context{Cols: 120, Rows: 30}) +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 < 6 { - t.Fatalf("two-panel welcome should have several rows, got %d", rows) + 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" } - // rounded box corners + two-panel content - if g.Cell(0, 0).Ch != '╭' || g.Cell(cols-1, 0).Ch != '╮' { - t.Errorf("top corners = %q/%q; want ╭/╮", g.Cell(0, 0).Ch, g.Cell(cols-1, 0).Ch) - } - for _, want := range []string{ - "opendbx", "v0.49.0", // title - "Welcome to opendbx!", // left greeting - "deepseek-v4-pro", "~/opendbx", // left model + cwd - "Tips for getting started", // right header - "为什么这条 SQL 慢", // right tip - } { + for _, want := range []string{"v0.49.0", "~/opendbx", "为什么这条 SQL 慢", "? for shortcuts"} { if !strings.Contains(all, want) { - t.Errorf("welcome missing %q; got:\n%s", want, all) + t.Errorf("welcome box missing %q; got:\n%s", want, all) } } - // the sparkle mascot quadrant glyphs appear - if !strings.ContainsRune(all, '▗') || !strings.ContainsRune(all, '▘') { - t.Errorf("welcome missing sparkle mascot ▗/▘; got:\n%s", all) - } // no row exceeds Cols (rule 20 Layer 1 invariant) for y := 0; y < rows; y++ { - if rw := width.Width(gridRow(t, buf, y)); rw > cols { - t.Errorf("row %d width %d exceeds cols %d", y, rw, cols) + if w := width.Width(gridRow(t, buf, y)); w > cols { + t.Errorf("row %d width %d exceeds cols %d", y, w, cols) } } } @@ -104,9 +99,9 @@ func TestWelcome_NarrowFallbackSingleLine(t *testing.T) { func TestWelcome_MeasureOnly(t *testing.T) { w := NewWelcome("v0.49.0", "~/opendbx", "tip") - full, _ := w.Render(Context{Cols: 120, Rows: 30}) + full, _ := w.Render(Context{Cols: 80, Rows: 24}) _, wantRows := full.(*buffer.Grid).Size() - mo, err := w.Render(Context{Cols: 120, Rows: 30, MeasureOnly: true}) + mo, err := w.Render(Context{Cols: 80, Rows: 24, MeasureOnly: true}) if err != nil { t.Fatalf("MeasureOnly err: %v", err) }