diff --git a/app/config.go b/app/config.go index 0fad394..7136527 100644 --- a/app/config.go +++ b/app/config.go @@ -36,7 +36,7 @@ func (c *Config) Validate() error { // ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。 func (c *Config) ValidateBase() error { if c.APIKey == "" { - return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)") + return fmt.Errorf("api key is required (set OPENROUTER_API_KEY, Z_OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY)") } switch c.Provider { case "openai", "anthropic", "gemini", "openrouter": diff --git a/app/run.go b/app/run.go index 98dc33c..7d1cf57 100644 --- a/app/run.go +++ b/app/run.go @@ -20,6 +20,7 @@ import ( "github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/tools" + "github.com/voocel/litellm" ) // emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。 @@ -38,6 +39,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s if err := cfg.Validate(); err != nil { return err } + log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir) // 1. 初始化状态 store := state.NewStore(cfg.OutputDir) @@ -61,10 +63,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s askUser.SetHandler(cliAskUserHandler) // 4. 确定性控制面:事件监听 + FollowUp 注入 - registerSubscription(coordinator, store, nil, nil, nil) + registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil) // 5. 初始化运行元信息(保留已有 SteerHistory) - if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { + if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil { log.Printf("[warn] 初始化运行元信息失败: %v", err) } @@ -115,7 +117,9 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s } // registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。 -func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit emitFn, onDelta deltaFn, onClear clearFn) { +func registerSubscription(coordinator *agentcore.Agent, store *state.Store, provider string, emit emitFn, onDelta deltaFn, onClear clearFn) { + var lastProgressSummary string + coordinator.Subscribe(func(ev agentcore.Event) { switch ev.Type { case agentcore.EventToolExecStart: @@ -133,6 +137,13 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit return } summary := parseProgressSummary(ev) + if summary == "" { + return + } + if summary == lastProgressSummary { + return + } + lastProgressSummary = summary log.Printf("[progress] %s", summary) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"}) @@ -151,6 +162,7 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit } case agentcore.EventToolExecEnd: + lastProgressSummary = "" if ev.IsError { log.Printf("[tool:error] %s", ev.Tool) if emit != nil { @@ -178,9 +190,9 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit } case agentcore.EventError: - log.Printf("[error] %v", ev.Err) + log.Printf("[error][provider=%s] %v", provider, ev.Err) if emit != nil { - emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("%v", ev.Err), Level: "error"}) + emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("[%s] %v", provider, ev.Err), Level: "error"}) } } }) @@ -460,14 +472,19 @@ func parseProgressSummary(ev agentcore.Event) string { return "progress" } var data struct { - Agent string `json:"agent"` - Tool string `json:"tool"` - Turn int `json:"turn"` - Error bool `json:"error"` + Agent string `json:"agent"` + Tool string `json:"tool"` + Turn int `json:"turn"` + Error bool `json:"error"` + Thinking string `json:"thinking"` } if err := json.Unmarshal(ev.Result, &data); err != nil { return truncateLog(string(ev.Result), 60) } + // subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。 + if data.Thinking != "" && data.Tool == "" { + return "" + } if data.Tool != "" { if data.Error { return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool) @@ -540,11 +557,25 @@ func createModel(cfg Config) (agentcore.ChatModel, error) { return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...) case "gemini": return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...) - default: // openai, openrouter 及其他 OpenAI 兼容服务 + case "openrouter": + return newOpenRouterModel(cfg.ModelName, cfg.APIKey, baseURL...) + default: // openai 及其他 OpenAI 兼容服务 return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) } } +func newOpenRouterModel(model, apiKey string, baseURL ...string) (agentcore.ChatModel, error) { + cfg := litellm.ProviderConfig{APIKey: apiKey} + if len(baseURL) > 0 { + cfg.BaseURL = baseURL[0] + } + client, err := litellm.NewWithProvider("openrouter", cfg) + if err != nil { + return nil, fmt.Errorf("openrouter: %w", err) + } + return llm.NewLiteLLMAdapter(model, client), nil +} + // cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。 func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) { resp := &tools.AskUserResponse{ diff --git a/app/run_test.go b/app/run_test.go index 9fc86eb..52c3364 100644 --- a/app/run_test.go +++ b/app/run_test.go @@ -1,8 +1,10 @@ package app import ( + "encoding/json" "testing" + "github.com/voocel/agentcore" "github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/state" ) @@ -70,3 +72,53 @@ func TestFinalizeSteerIfIdleKeepsActiveFlow(t *testing.T) { t.Fatalf("expected pending steer preserved, got %q", runMeta.PendingSteer) } } + +func TestParseProgressSummaryIgnoresThinkingUpdate(t *testing.T) { + result, err := json.Marshal(map[string]any{ + "agent": "architect", + "thinking": "好的,我已经获得了模板。", + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + summary := parseProgressSummary(agentcore.Event{Result: result}) + if summary != "" { + t.Fatalf("expected thinking update to be ignored, got %q", summary) + } +} + +func TestParseProgressSummaryKeepsToolProgress(t *testing.T) { + result, err := json.Marshal(map[string]any{ + "agent": "writer", + "tool": "plan_chapter", + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + summary := parseProgressSummary(agentcore.Event{Result: result}) + if summary != "writer → plan_chapter" { + t.Fatalf("unexpected summary: %q", summary) + } +} + +func TestCreateModelUsesOpenRouterProvider(t *testing.T) { + model, err := createModel(Config{ + Provider: "openrouter", + ModelName: "stepfun/step-3.5-flash:free", + APIKey: "test-key", + BaseURL: "https://openrouter.ai/api/v1", + }) + if err != nil { + t.Fatalf("createModel: %v", err) + } + + providerModel, ok := model.(interface{ ProviderName() string }) + if !ok { + t.Fatalf("model does not expose provider name") + } + if provider := providerModel.ProviderName(); provider != "openrouter" { + t.Fatalf("expected provider openrouter, got %q", provider) + } +} diff --git a/app/runtime.go b/app/runtime.go index d9487e2..cb702a0 100644 --- a/app/runtime.go +++ b/app/runtime.go @@ -25,6 +25,7 @@ type UIEvent struct { // UISnapshot 是 TUI 渲染所需的聚合状态快照。 type UISnapshot struct { + Provider string NovelName string ModelName string Style string @@ -94,6 +95,7 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s if err := cfg.ValidateBase(); err != nil { return nil, err } + log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir) store := state.NewStore(cfg.OutputDir) if err := store.Init(); err != nil { @@ -119,10 +121,10 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s } // 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发 - registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear) + registerSubscription(coordinator, store, cfg.Provider, rt.emit, rt.emitDelta, rt.emitClear) // 初始化运行元信息 - if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { + if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil { log.Printf("[warn] 初始化运行元信息失败: %v", err) } @@ -249,6 +251,7 @@ func (rt *Runtime) Steer(text string) { func (rt *Runtime) Snapshot() UISnapshot { snap := UISnapshot{ NovelName: rt.cfg.NovelName, + Provider: rt.cfg.Provider, ModelName: rt.cfg.ModelName, Style: rt.cfg.Style, } diff --git a/domain/runtime.go b/domain/runtime.go index 5dba9fe..bf10300 100644 --- a/domain/runtime.go +++ b/domain/runtime.go @@ -62,6 +62,7 @@ func (p *Progress) NextChapter() int { // RunMeta 运行元信息,持久化到 meta/run.json。 type RunMeta struct { StartedAt string `json:"started_at"` + Provider string `json:"provider,omitempty"` Style string `json:"style"` Model string `json:"model"` SteerHistory []SteerEntry `json:"steer_history,omitempty"` diff --git a/state/run_meta.go b/state/run_meta.go index 63238f9..c7f8b7b 100644 --- a/state/run_meta.go +++ b/state/run_meta.go @@ -26,10 +26,11 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { } // InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。 -func (s *Store) InitRunMeta(style, model string) error { +func (s *Store) InitRunMeta(style, provider, model string) error { existing, _ := s.LoadRunMeta() meta := domain.RunMeta{ StartedAt: time.Now().Format(time.RFC3339), + Provider: provider, Style: style, Model: model, } diff --git a/state/run_meta_test.go b/state/run_meta_test.go index d6b3446..5ab7d13 100644 --- a/state/run_meta_test.go +++ b/state/run_meta_test.go @@ -13,6 +13,7 @@ func TestSaveAndLoadRunMeta(t *testing.T) { meta := domain.RunMeta{ StartedAt: "2026-03-07T10:00:00+08:00", + Provider: "openrouter", Style: "fantasy", Model: "gpt-4o", } @@ -27,6 +28,9 @@ func TestSaveAndLoadRunMeta(t *testing.T) { if loaded.Style != "fantasy" { t.Errorf("style mismatch: %s", loaded.Style) } + if loaded.Provider != "openrouter" { + t.Errorf("provider mismatch: %s", loaded.Provider) + } if loaded.Model != "gpt-4o" { t.Errorf("model mismatch: %s", loaded.Model) } @@ -80,6 +84,7 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) { // 先保存 RunMeta _ = store.SaveRunMeta(domain.RunMeta{ StartedAt: "2026-03-07T10:00:00+08:00", + Provider: "openrouter", Style: "suspense", Model: "gpt-4o", }) @@ -91,6 +96,9 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) { if meta.Style != "suspense" { t.Errorf("style should be preserved, got %s", meta.Style) } + if meta.Provider != "openrouter" { + t.Errorf("provider should be preserved, got %s", meta.Provider) + } if meta.Model != "gpt-4o" { t.Errorf("model should be preserved, got %s", meta.Model) } @@ -106,6 +114,7 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) { // 先建立带历史的 RunMeta _ = store.SaveRunMeta(domain.RunMeta{ StartedAt: "old", + Provider: "openai", Style: "fantasy", Model: "old-model", SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}}, @@ -113,12 +122,15 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) { }) // InitRunMeta 应保留 SteerHistory 和 PendingSteer - _ = store.InitRunMeta("suspense", "new-model") + _ = store.InitRunMeta("suspense", "openrouter", "new-model") meta, _ := store.LoadRunMeta() if meta.Style != "suspense" { t.Errorf("style should be updated, got %s", meta.Style) } + if meta.Provider != "openrouter" { + t.Errorf("provider should be updated, got %s", meta.Provider) + } if meta.Model != "new-model" { t.Errorf("model should be updated, got %s", meta.Model) } diff --git a/tui/input.go b/tui/input.go index b949a3f..85bd2cb 100644 --- a/tui/input.go +++ b/tui/input.go @@ -20,7 +20,7 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid line1 := prompt + inputView // 第二行:左快捷键,右进度 - hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送") + hints := lipgloss.NewStyle().Foreground(colorDim).Render("点击/Tab 切换面板 · ↑↓ 滚动 · End 跳底 · ^L 清屏 · Esc 重置 · Enter 发送") info := buildRightInfo(snap, outputDir) hintsW := lipgloss.Width(hints) @@ -52,6 +52,9 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid func buildRightInfo(snap app.UISnapshot, outputDir string) string { var parts []string + if snap.Provider != "" { + parts = append(parts, snap.Provider) + } if snap.ModelName != "" { parts = append(parts, snap.ModelName) } diff --git a/tui/model.go b/tui/model.go index 87d0ecc..8be193f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -13,6 +13,14 @@ import ( const maxEvents = 500 +type focusPane int + +const ( + focusEvents focusPane = iota + focusStream + focusDetail +) + type appMode int const ( @@ -29,15 +37,18 @@ type Model struct { runtime *app.Runtime snapshot app.UISnapshot events []app.UIEvent - viewport viewport.Model // 事件流 viewport - streamVP viewport.Model // 流式输出 viewport + viewport viewport.Model // 事件流 viewport + streamVP viewport.Model // 流式输出 viewport + detailVP viewport.Model // 右侧详情 viewport streamBuf *strings.Builder // 流式文本累积缓冲 textarea textarea.Model width int height int autoScroll bool streamScroll bool // 流式面板自动跟随 - focusStream bool // true=焦点在流式面板, false=事件流 + focusPane focusPane + hoverPane focusPane + hoverActive bool mode appMode err error spinnerIdx int @@ -63,6 +74,9 @@ func NewModel(rt *app.Runtime) Model { svp := viewport.New(80, 10) svp.SetContent("") + dvp := viewport.New(40, 20) + dvp.SetContent("") + return Model{ runtime: rt, autoScroll: true, @@ -71,6 +85,7 @@ func NewModel(rt *app.Runtime) Model { textarea: ta, viewport: vp, streamVP: svp, + detailVP: dvp, streamBuf: &strings.Builder{}, } } @@ -97,6 +112,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.textarea.SetWidth(m.inputWidth()) m.updateViewportSize() + m.refreshDetailViewport() return m, nil case tea.KeyMsg: @@ -116,7 +132,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamRound = 0 return m, nil case tea.KeyTab: - m.focusStream = !m.focusStream + m.focusPane = (m.focusPane + 1) % 3 return m, nil case tea.KeyEnter: text := strings.TrimSpace(m.textarea.Value()) @@ -134,18 +150,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyUp, tea.KeyPgUp: - if m.focusStream { + if m.focusPane == focusStream { m.streamScroll = false var cmd tea.Cmd m.streamVP, cmd = m.streamVP.Update(msg) return m, cmd } + if m.focusPane == focusDetail { + var cmd tea.Cmd + m.detailVP, cmd = m.detailVP.Update(msg) + return m, cmd + } m.autoScroll = false var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd case tea.KeyDown, tea.KeyPgDown: - if m.focusStream { + if m.focusPane == focusStream { var cmd tea.Cmd m.streamVP, cmd = m.streamVP.Update(msg) if m.streamVP.AtBottom() { @@ -153,6 +174,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, cmd } + if m.focusPane == focusDetail { + var cmd tea.Cmd + m.detailVP, cmd = m.detailVP.Update(msg) + return m, cmd + } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) if m.viewport.AtBottom() { @@ -160,9 +186,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, cmd case tea.KeyEnd: - if m.focusStream { + if m.focusPane == focusStream { m.streamScroll = true m.streamVP.GotoBottom() + } else if m.focusPane == focusDetail { + m.detailVP.GotoBottom() } else { m.autoScroll = true m.viewport.GotoBottom() @@ -171,12 +199,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseMsg: + if pane, ok := m.paneAtMouse(msg.X, msg.Y); ok { + m.hoverPane = pane + m.hoverActive = true + if msg.Action == tea.MouseActionPress { + m.focusPane = pane + } + } else { + m.hoverActive = false + } var cmd tea.Cmd - if m.focusStream { + if m.focusPane == focusStream { m.streamVP, cmd = m.streamVP.Update(msg) if msg.Action == tea.MouseActionPress { m.streamScroll = m.streamVP.AtBottom() } + } else if m.focusPane == focusDetail { + m.detailVP, cmd = m.detailVP.Update(msg) } else { m.viewport, cmd = m.viewport.Update(msg) if msg.Action == tea.MouseActionPress { @@ -196,6 +235,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case snapshotMsg: m.snapshot = app.UISnapshot(msg) + m.refreshDetailViewport() return m, tickSnapshot(m.runtime) case doneMsg: @@ -260,6 +300,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *Model) paneAtMouse(x, y int) (focusPane, bool) { + if m.width == 0 || m.height == 0 { + return focusEvents, false + } + + topH := lipgloss.Height(renderTopBar(m.snapshot, m.width, "")) + inputH := lipgloss.Height(renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width)) + bodyH := m.height - topH - inputH + if bodyH < 1 { + return focusEvents, false + } + + bodyStartY := topH + bodyEndY := topH + bodyH + if y < bodyStartY || y >= bodyEndY { + return focusEvents, false + } + + leftW := m.width * 25 / 100 + rightW := m.detailWidth() + centerStartX := leftW + rightStartX := m.width - rightW + + if x >= rightStartX { + return focusDetail, true + } + if x < centerStartX { + return focusEvents, true + } + + eventH, _ := m.splitHeights(bodyH) + if y-bodyStartY < eventH { + return focusEvents, true + } + return focusStream, true +} + +func (m *Model) paneHighlighted(pane focusPane) bool { + if m.focusPane == pane { + return true + } + return m.hoverActive && m.hoverPane == pane +} + // refreshEventViewport 重新渲染事件流内容并设置 viewport。 func (m *Model) refreshEventViewport() { centerW := m.eventFlowWidth() @@ -273,15 +357,26 @@ func (m *Model) refreshEventViewport() { } } +func (m *Model) refreshDetailViewport() { + rightW := m.detailWidth() + if rightW <= 4 { + return + } + m.detailVP.SetContent(renderDetailContent(m.snapshot, rightW-4)) +} + // updateViewportSize 根据当前窗口尺寸更新 viewport 大小。 func (m *Model) updateViewportSize() { centerW := m.eventFlowWidth() + rightW := m.detailWidth() bodyH := m.bodyHeight() eventH, streamH := m.splitHeights(bodyH) m.viewport.Width = centerW - 2 m.viewport.Height = eventH - 1 // -1 为 event panel header 行 m.streamVP.Width = centerW - 2 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 + m.detailVP.Width = rightW - 2 + m.detailVP.Height = bodyH } // splitHeights 计算事件流和流式输出的高度分配。 @@ -309,10 +404,17 @@ func (m *Model) eventFlowWidth() int { return 80 } leftW := m.width * 25 / 100 - rightW := m.width * 30 / 100 + rightW := m.detailWidth() return m.width - leftW - rightW } +func (m *Model) detailWidth() int { + if m.width == 0 { + return 40 + } + return m.width * 30 / 100 +} + func (m *Model) bodyHeight() int { if m.height == 0 { return 20 @@ -362,7 +464,7 @@ func (m Model) View() string { body = renderWelcome(m.width, bodyH, errMsg) } else { leftW := m.width * 25 / 100 - rightW := m.width * 30 / 100 + rightW := m.detailWidth() centerW := m.width - leftW - rightW eventH, streamH := m.splitHeights(bodyH) @@ -375,12 +477,12 @@ func (m Model) View() string { m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 } - eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream) - streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream) + eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, m.paneHighlighted(focusEvents)) + streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.paneHighlighted(focusStream)) center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel) left := renderStatePanel(m.snapshot, leftW, bodyH) - right := renderDetailPanel(m.snapshot, rightW, bodyH) + right := renderDetailPanel(m.detailVP, rightW, bodyH, m.paneHighlighted(focusDetail)) body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right) } diff --git a/tui/panels.go b/tui/panels.go index a548e88..cc42792 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -27,6 +27,9 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string { // 第二行左侧:模型 + 风格 var infoParts []string + if snap.Provider != "" { + infoParts = append(infoParts, snap.Provider) + } if snap.ModelName != "" { infoParts = append(infoParts, snap.ModelName) } @@ -238,10 +241,9 @@ func renderStreamSeparator(round, width int) string { return dimLine + dimLabel + dimLine } -// renderDetailPanel 渲染右侧详情面板。 +// renderDetailContent 构建右侧详情面板内容。 // 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。 -func renderDetailPanel(snap app.UISnapshot, width, height int) string { - contentW := width - 4 // 边框 + padding +func renderDetailContent(snap app.UISnapshot, contentW int) string { var b strings.Builder // 大纲 @@ -309,14 +311,23 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string { } } + return b.String() +} + +// renderDetailPanel 渲染右侧可滚动详情面板。 +func renderDetailPanel(vp viewport.Model, width, height int, focused bool) string { + borderColor := colorDim + if focused { + borderColor = colorAccent + } style := lipgloss.NewStyle(). Width(width). Height(height). Border(baseBorder, false, false, false, true). - BorderForeground(colorDim). + BorderForeground(borderColor). Padding(0, 1) - return style.Render(b.String()) + return style.Render(vp.View()) } // renderWelcome 渲染新建态首屏。