feat: add ask user question
This commit is contained in:
@@ -9,12 +9,14 @@ import (
|
||||
|
||||
// 消息类型
|
||||
type (
|
||||
eventMsg app.UIEvent
|
||||
snapshotMsg app.UISnapshot
|
||||
doneMsg struct{}
|
||||
eventMsg app.UIEvent
|
||||
snapshotMsg app.UISnapshot
|
||||
doneMsg struct{}
|
||||
startResultMsg struct{ err error }
|
||||
steerResultMsg struct{}
|
||||
spinnerTickMsg time.Time
|
||||
streamDeltaMsg string // 流式 token 增量
|
||||
streamClearMsg struct{} // 清空流式缓冲(新消息开始)
|
||||
)
|
||||
|
||||
// --- Cmd 函数 ---
|
||||
@@ -76,7 +78,27 @@ func steerRuntime(rt *app.Runtime, text string) tea.Cmd {
|
||||
}
|
||||
|
||||
func tickSpinner() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return tea.Tick(350*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return spinnerTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func listenStream(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
delta, ok := <-rt.Stream()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return streamDeltaMsg(delta)
|
||||
}
|
||||
}
|
||||
|
||||
func listenStreamClear(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, ok := <-rt.StreamClear()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return streamClearMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
61
tui/input.go
61
tui/input.go
@@ -1,14 +1,67 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。
|
||||
func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string {
|
||||
// 左侧:快捷键提示
|
||||
keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc")
|
||||
|
||||
// 右侧:进度 + 输出目录
|
||||
right := buildRightInfo(snap, outputDir)
|
||||
|
||||
// 中间:输入框,自适应宽度
|
||||
leftW := lipgloss.Width(keys)
|
||||
rightW := lipgloss.Width(right)
|
||||
sep := lipgloss.NewStyle().Foreground(colorDim).Render(" │ ")
|
||||
sepW := lipgloss.Width(sep)
|
||||
inputW := width - leftW - rightW - sepW*2 - 4 // 4 为 padding+border 余量
|
||||
if inputW < 20 {
|
||||
inputW = 20
|
||||
}
|
||||
|
||||
input := lipgloss.NewStyle().Width(inputW).Render(inputView)
|
||||
|
||||
content := keys + sep + input + sep + right
|
||||
|
||||
// renderInputBox 渲染底部输入框区域。
|
||||
func renderInputBox(inputView string, width int) string {
|
||||
style := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Border(baseBorder, true, false, false, false).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
return style.Render(inputView)
|
||||
return style.Render(content)
|
||||
}
|
||||
|
||||
// buildRightInfo 构建右侧进度和目录信息。
|
||||
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
||||
var parts []string
|
||||
|
||||
// 章节进度
|
||||
if snap.TotalChapters > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Ch %d/%d", snap.CompletedCount, snap.TotalChapters))
|
||||
}
|
||||
|
||||
// 字数
|
||||
if snap.TotalWordCount > 0 {
|
||||
parts = append(parts, formatNumber(snap.TotalWordCount)+"字")
|
||||
}
|
||||
|
||||
// 输出目录(缩短为相对路径的最后一段)
|
||||
if outputDir != "" {
|
||||
dir := filepath.Base(outputDir)
|
||||
parts = append(parts, "./"+dir)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return lipgloss.NewStyle().Foreground(colorDim).Render("READY")
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
147
tui/model.go
147
tui/model.go
@@ -21,7 +21,7 @@ const (
|
||||
modeDone // 创作完成
|
||||
)
|
||||
|
||||
// spinner 帧序列
|
||||
// 顶栏 spinner 帧序列
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// Model 是 TUI 的顶层状态。
|
||||
@@ -29,11 +29,15 @@ type Model struct {
|
||||
runtime *app.Runtime
|
||||
snapshot app.UISnapshot
|
||||
events []app.UIEvent
|
||||
viewport viewport.Model
|
||||
viewport viewport.Model // 事件流 viewport
|
||||
streamVP viewport.Model // 流式输出 viewport
|
||||
streamBuf strings.Builder // 流式文本累积缓冲
|
||||
textarea textarea.Model
|
||||
width int
|
||||
height int
|
||||
autoScroll bool
|
||||
streamScroll bool // 流式面板自动跟随
|
||||
focusStream bool // true=焦点在流式面板, false=事件流
|
||||
mode appMode
|
||||
err error
|
||||
spinnerIdx int
|
||||
@@ -44,7 +48,7 @@ func NewModel(rt *app.Runtime) Model {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||
ta.CharLimit = 500
|
||||
ta.MaxHeight = 3
|
||||
ta.MaxHeight = 1
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Focus()
|
||||
|
||||
@@ -54,12 +58,17 @@ func NewModel(rt *app.Runtime) Model {
|
||||
vp := viewport.New(80, 20)
|
||||
vp.SetContent("")
|
||||
|
||||
svp := viewport.New(80, 10)
|
||||
svp.SetContent("")
|
||||
|
||||
return Model{
|
||||
runtime: rt,
|
||||
autoScroll: true,
|
||||
mode: modeNew,
|
||||
textarea: ta,
|
||||
viewport: vp,
|
||||
runtime: rt,
|
||||
autoScroll: true,
|
||||
streamScroll: true,
|
||||
mode: modeNew,
|
||||
textarea: ta,
|
||||
viewport: vp,
|
||||
streamVP: svp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +77,8 @@ func (m Model) Init() tea.Cmd {
|
||||
textarea.Blink,
|
||||
listenEvents(m.runtime),
|
||||
listenDone(m.runtime),
|
||||
listenStream(m.runtime),
|
||||
listenStreamClear(m.runtime),
|
||||
tickSnapshot(m.runtime),
|
||||
checkResume(m.runtime),
|
||||
tickSpinner(),
|
||||
@@ -81,7 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.textarea.SetWidth(m.width - 4)
|
||||
m.textarea.SetWidth(m.inputWidth())
|
||||
m.updateViewportSize()
|
||||
return m, nil
|
||||
|
||||
@@ -96,6 +107,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.events = nil
|
||||
m.viewport.SetContent("")
|
||||
m.viewport.GotoTop()
|
||||
m.streamBuf.Reset()
|
||||
m.streamVP.SetContent("")
|
||||
m.streamVP.GotoTop()
|
||||
return m, nil
|
||||
case tea.KeyTab:
|
||||
m.focusStream = !m.focusStream
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
text := strings.TrimSpace(m.textarea.Value())
|
||||
@@ -113,31 +130,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyUp, tea.KeyPgUp:
|
||||
if m.focusStream {
|
||||
m.streamScroll = false
|
||||
var cmd tea.Cmd
|
||||
m.streamVP, cmd = m.streamVP.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 {
|
||||
var cmd tea.Cmd
|
||||
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||
if m.streamVP.AtBottom() {
|
||||
m.streamScroll = true
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
// 滚动到底部时恢复自动跟随
|
||||
if m.viewport.AtBottom() {
|
||||
m.autoScroll = true
|
||||
}
|
||||
return m, cmd
|
||||
case tea.KeyEnd:
|
||||
m.autoScroll = true
|
||||
m.viewport.GotoBottom()
|
||||
if m.focusStream {
|
||||
m.streamScroll = true
|
||||
m.streamVP.GotoBottom()
|
||||
} else {
|
||||
m.autoScroll = true
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case tea.MouseMsg:
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
if msg.Action == tea.MouseActionPress {
|
||||
m.autoScroll = false
|
||||
if m.viewport.AtBottom() {
|
||||
m.autoScroll = true
|
||||
if m.focusStream {
|
||||
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||
if msg.Action == tea.MouseActionPress {
|
||||
m.streamScroll = m.streamVP.AtBottom()
|
||||
}
|
||||
} else {
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
if msg.Action == tea.MouseActionPress {
|
||||
m.autoScroll = m.viewport.AtBottom()
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
@@ -181,7 +220,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case spinnerTickMsg:
|
||||
m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames)
|
||||
if m.snapshot.IsRunning {
|
||||
m.refreshEventViewport()
|
||||
}
|
||||
return m, tickSpinner()
|
||||
|
||||
case streamDeltaMsg:
|
||||
m.streamBuf.WriteString(string(msg))
|
||||
m.streamVP.SetContent(m.streamBuf.String())
|
||||
if m.streamScroll {
|
||||
m.streamVP.GotoBottom()
|
||||
}
|
||||
return m, listenStream(m.runtime)
|
||||
|
||||
case streamClearMsg:
|
||||
m.streamBuf.Reset()
|
||||
m.streamVP.SetContent("")
|
||||
m.streamVP.GotoTop()
|
||||
m.streamScroll = true
|
||||
return m, listenStreamClear(m.runtime)
|
||||
}
|
||||
|
||||
// 更新 textarea 组件
|
||||
@@ -196,6 +253,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m *Model) refreshEventViewport() {
|
||||
centerW := m.eventFlowWidth()
|
||||
content := renderEventContent(m.events, centerW)
|
||||
if m.snapshot.IsRunning {
|
||||
content += renderSparkle(m.spinnerIdx)
|
||||
}
|
||||
m.viewport.SetContent(content)
|
||||
if m.autoScroll {
|
||||
m.viewport.GotoBottom()
|
||||
@@ -206,8 +266,39 @@ func (m *Model) refreshEventViewport() {
|
||||
func (m *Model) updateViewportSize() {
|
||||
centerW := m.eventFlowWidth()
|
||||
bodyH := m.bodyHeight()
|
||||
eventH, streamH := m.splitHeights(bodyH)
|
||||
m.viewport.Width = centerW - 2
|
||||
m.viewport.Height = bodyH
|
||||
m.viewport.Height = eventH
|
||||
m.streamVP.Width = centerW - 2
|
||||
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||
}
|
||||
|
||||
// splitHeights 计算事件流和流式输出的高度分配。
|
||||
func (m *Model) splitHeights(bodyH int) (eventH, streamH int) {
|
||||
eventH = bodyH * 40 / 100
|
||||
if eventH < 3 {
|
||||
eventH = 3
|
||||
}
|
||||
streamH = bodyH - eventH - 1 // -1 为分隔线
|
||||
if streamH < 3 {
|
||||
streamH = 3
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Model) inputWidth() int {
|
||||
if m.width == 0 {
|
||||
return 60
|
||||
}
|
||||
// 与 renderInputBox 中 inputW 计算一致
|
||||
keysW := 10 // "Tab·^L·Esc"
|
||||
rightW := 30 // 进度+目录预估
|
||||
sepW := 3 * 2
|
||||
w := m.width - keysW - rightW - sepW - 4
|
||||
if w < 20 {
|
||||
w = 20
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m *Model) eventFlowWidth() int {
|
||||
@@ -224,7 +315,7 @@ func (m *Model) bodyHeight() int {
|
||||
return 20
|
||||
}
|
||||
topH := 1
|
||||
inputH := 3
|
||||
inputH := 2 // 单行输入 + top border
|
||||
bodyH := m.height - topH - inputH
|
||||
if bodyH < 3 {
|
||||
bodyH = 3
|
||||
@@ -246,11 +337,11 @@ func (m Model) View() string {
|
||||
|
||||
spinnerFrame := ""
|
||||
if m.snapshot.IsRunning {
|
||||
spinnerFrame = spinnerFrames[m.spinnerIdx]
|
||||
spinnerFrame = spinnerFrames[m.spinnerIdx%len(spinnerFrames)]
|
||||
}
|
||||
|
||||
topBar := renderTopBar(m.snapshot, m.width, spinnerFrame)
|
||||
inputBox := renderInputBox(m.textarea.View(), m.width)
|
||||
inputBox := renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width)
|
||||
|
||||
topH := lipgloss.Height(topBar)
|
||||
inputH := lipgloss.Height(inputBox)
|
||||
@@ -270,14 +361,22 @@ func (m Model) View() string {
|
||||
leftW := m.width * 25 / 100
|
||||
rightW := m.width * 30 / 100
|
||||
centerW := m.width - leftW - rightW
|
||||
eventH, streamH := m.splitHeights(bodyH)
|
||||
|
||||
if m.viewport.Width != centerW-2 || m.viewport.Height != bodyH {
|
||||
if m.viewport.Width != centerW-2 || m.viewport.Height != eventH {
|
||||
m.viewport.Width = centerW - 2
|
||||
m.viewport.Height = bodyH
|
||||
m.viewport.Height = eventH
|
||||
}
|
||||
if m.streamVP.Width != centerW-2 || m.streamVP.Height != streamH-1 {
|
||||
m.streamVP.Width = centerW - 2
|
||||
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||
}
|
||||
|
||||
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH)
|
||||
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
|
||||
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
|
||||
|
||||
left := renderStatePanel(m.snapshot, leftW, bodyH)
|
||||
center := renderEventFlowViewport(m.viewport, centerW, bodyH)
|
||||
right := renderDetailPanel(m.snapshot, rightW, bodyH)
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
||||
}
|
||||
|
||||
155
tui/panels.go
155
tui/panels.go
@@ -9,15 +9,33 @@ import (
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
// renderTopBar 渲染顶部状态栏。
|
||||
// renderTopBar 渲染顶部状态栏(两行布局)。
|
||||
// 第一行:小说名居中
|
||||
// 第二行:左侧模型/风格信息,右侧状态胶囊
|
||||
func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
||||
left := lipgloss.NewStyle().Foreground(colorText).Bold(true).Render(snap.NovelName)
|
||||
if snap.Style != "" && snap.Style != "default" {
|
||||
left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.Style)
|
||||
// 第一行:小说名居中
|
||||
novelName := snap.NovelName
|
||||
if novelName == "" {
|
||||
novelName = "Novel Agent"
|
||||
}
|
||||
left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.ModelName)
|
||||
line1 := lipgloss.NewStyle().
|
||||
Width(width - 2).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
Foreground(colorText).
|
||||
Bold(true).
|
||||
Render("✦ " + novelName + " ✦")
|
||||
|
||||
// 状态胶囊
|
||||
// 第二行左侧:模型 + 风格
|
||||
var infoParts []string
|
||||
if snap.ModelName != "" {
|
||||
infoParts = append(infoParts, snap.ModelName)
|
||||
}
|
||||
if snap.Style != "" && snap.Style != "default" {
|
||||
infoParts = append(infoParts, snap.Style)
|
||||
}
|
||||
left := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(infoParts, " · "))
|
||||
|
||||
// 第二行右侧:状态胶囊
|
||||
label := snap.StatusLabel
|
||||
if label == "" {
|
||||
label = "READY"
|
||||
@@ -28,18 +46,21 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
||||
}
|
||||
capsule := statusCapsule.Foreground(lipgloss.Color("#1a1a2e")).Background(color).Render(label)
|
||||
|
||||
// Spinner(运行中显示)
|
||||
if snap.IsRunning && spinnerFrame != "" {
|
||||
capsule = lipgloss.NewStyle().Foreground(colorAccent).Render(spinnerFrame) + " " + capsule
|
||||
}
|
||||
|
||||
// 左右填充
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(capsule) - 2
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
line2 := left + strings.Repeat(" ", gap) + capsule
|
||||
|
||||
return topBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + capsule)
|
||||
content := line1 + "\n" + line2
|
||||
return topBarStyle.Width(width).
|
||||
Border(baseBorder, false, false, true, false).
|
||||
BorderForeground(colorDim).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// renderStatePanel 渲染左侧状态面板。
|
||||
@@ -89,6 +110,40 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
|
||||
return style.Render(b.String())
|
||||
}
|
||||
|
||||
// 星光动画帧
|
||||
var sparklePatterns = []string{
|
||||
" ✦ · ✧ · ",
|
||||
" · ✧ ✦ · ",
|
||||
" · ✦ · ✧ ",
|
||||
" ✧ · ✧ ✦ ·",
|
||||
" ✦ · ✧ · ",
|
||||
" · ✧ ✦ · ",
|
||||
" ✧ · · ✦ ✧ ",
|
||||
" ✦ · ✧ ✦ · ",
|
||||
}
|
||||
|
||||
// renderSparkle 渲染事件流底部的星光加载动画。
|
||||
func renderSparkle(frame int) string {
|
||||
idx := frame % len(sparklePatterns)
|
||||
// 亮星用琥珀色,暗星用灰色
|
||||
line := sparklePatterns[idx]
|
||||
var b strings.Builder
|
||||
for _, ch := range line {
|
||||
switch ch {
|
||||
case '✦':
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Render("✦"))
|
||||
case '✧':
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render("✧"))
|
||||
case '·':
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render("·"))
|
||||
default:
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
label := lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render(" AI 生成中…")
|
||||
return "\n" + b.String() + "\n" + label
|
||||
}
|
||||
|
||||
// renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。
|
||||
func renderEventContent(events []app.UIEvent, width int) string {
|
||||
var b strings.Builder
|
||||
@@ -124,10 +179,83 @@ func renderEventFlowViewport(vp viewport.Model, width, height int) string {
|
||||
return style.Render(vp.View())
|
||||
}
|
||||
|
||||
// renderStreamPanel 渲染流式输出面板(中间列下半部分)。
|
||||
func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string {
|
||||
// 分隔标题栏
|
||||
titleColor := colorDim
|
||||
if focused {
|
||||
titleColor = colorAccent
|
||||
}
|
||||
title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容")
|
||||
lineW := width - lipgloss.Width(title) - 4
|
||||
if lineW < 0 {
|
||||
lineW = 0
|
||||
}
|
||||
separator := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW))
|
||||
header := " " + title + " " + separator
|
||||
|
||||
// viewport 内容(height 包含 header 行,viewport 实际高度需减 1)
|
||||
vpH := height - 1
|
||||
if vpH < 1 {
|
||||
vpH = 1
|
||||
}
|
||||
vpStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Height(vpH).
|
||||
Padding(0, 1).
|
||||
Foreground(colorText)
|
||||
|
||||
return header + "\n" + vpStyle.Render(vp.View())
|
||||
}
|
||||
|
||||
// renderDetailPanel 渲染右侧详情面板。
|
||||
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||
contentW := width - 4 // 边框 + padding
|
||||
var b strings.Builder
|
||||
|
||||
// 大纲
|
||||
if len(snap.Outline) > 0 {
|
||||
b.WriteString(panelTitleStyle.Render("大纲"))
|
||||
b.WriteString("\n")
|
||||
for _, e := range snap.Outline {
|
||||
ch := fmt.Sprintf("%2d", e.Chapter)
|
||||
// 已完成的章节用绿色标记
|
||||
marker := lipgloss.NewStyle().Foreground(colorDim).Render("○")
|
||||
if snap.CompletedCount >= e.Chapter {
|
||||
marker = lipgloss.NewStyle().Foreground(colorSuccess).Render("●")
|
||||
} else if snap.InProgressChapter == e.Chapter {
|
||||
marker = lipgloss.NewStyle().Foreground(colorAccent).Render("◐")
|
||||
}
|
||||
title := truncate(e.Title, contentW-6)
|
||||
line := marker + lipgloss.NewStyle().Foreground(colorDim).Render(ch) + " " +
|
||||
cardContentStyle.Render(title)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// 角色
|
||||
if len(snap.Characters) > 0 {
|
||||
b.WriteString(panelTitleStyle.Render("角色"))
|
||||
b.WriteString("\n")
|
||||
for _, c := range snap.Characters {
|
||||
b.WriteString(cardContentStyle.Render("· " + truncate(c, contentW-2)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// 前提
|
||||
if snap.Premise != "" {
|
||||
b.WriteString(panelTitleStyle.Render("前提"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(truncate(snap.Premise, contentW*3)))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// 运行时信息
|
||||
if snap.LastCommitSummary != "" {
|
||||
b.WriteString(cardTitleStyle.Render("─ 最近提交 ─"))
|
||||
b.WriteString("\n")
|
||||
@@ -142,18 +270,11 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if snap.LastCheckpointName != "" {
|
||||
b.WriteString(cardTitleStyle.Render("─ 检查点 ─"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(cardContentStyle.Render(snap.LastCheckpointName))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if len(snap.RecentSummaries) > 0 {
|
||||
b.WriteString(cardTitleStyle.Render("─ 摘要 ─"))
|
||||
b.WriteString("\n")
|
||||
for _, s := range snap.RecentSummaries {
|
||||
b.WriteString(cardContentStyle.Render(s))
|
||||
b.WriteString(cardContentStyle.Render(truncate(s, contentW)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user