feat: support tui
This commit is contained in:
50
tui/app.go
Normal file
50
tui/app.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
"github.com/voocel/ainovel-cli/tools"
|
||||
)
|
||||
|
||||
// Run 启动 TUI 模式。
|
||||
func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[string]string) error {
|
||||
rt, err := app.NewRuntime(cfg, refs, prompts, styles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreLog := redirectLogger(rt.Dir())
|
||||
defer restoreLog()
|
||||
defer rt.Close()
|
||||
|
||||
m := NewModel(rt)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
// redirectLogger 将标准日志重定向到文件,避免破坏 TUI 画面。
|
||||
func redirectLogger(outputDir string) func() {
|
||||
prev := log.Writer()
|
||||
logPath := filepath.Join(outputDir, "meta", "tui.log")
|
||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||||
log.SetOutput(io.Discard)
|
||||
return func() { log.SetOutput(prev) }
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.SetOutput(io.Discard)
|
||||
return func() { log.SetOutput(prev) }
|
||||
}
|
||||
|
||||
log.SetOutput(f)
|
||||
return func() {
|
||||
log.SetOutput(prev)
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
82
tui/events.go
Normal file
82
tui/events.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
// 消息类型
|
||||
type (
|
||||
eventMsg app.UIEvent
|
||||
snapshotMsg app.UISnapshot
|
||||
doneMsg struct{}
|
||||
startResultMsg struct{ err error }
|
||||
steerResultMsg struct{}
|
||||
spinnerTickMsg time.Time
|
||||
)
|
||||
|
||||
// --- Cmd 函数 ---
|
||||
|
||||
func listenEvents(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ev, ok := <-rt.Events()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return eventMsg(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func listenDone(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
<-rt.Done()
|
||||
return doneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func tickSnapshot(rt *app.Runtime) tea.Cmd {
|
||||
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
|
||||
return snapshotMsg(rt.Snapshot())
|
||||
})
|
||||
}
|
||||
|
||||
func fetchSnapshot(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return snapshotMsg(rt.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func checkResume(rt *app.Runtime) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
label, err := rt.Resume()
|
||||
if err != nil {
|
||||
return startResultMsg{err: err}
|
||||
}
|
||||
if label != "" {
|
||||
return startResultMsg{err: nil}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func startRuntime(rt *app.Runtime, prompt string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := rt.Start(prompt)
|
||||
return startResultMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func steerRuntime(rt *app.Runtime, text string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
rt.Steer(text)
|
||||
return steerResultMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func tickSpinner() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return spinnerTickMsg(t)
|
||||
})
|
||||
}
|
||||
14
tui/input.go
Normal file
14
tui/input.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// 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)
|
||||
}
|
||||
53
tui/layout.go
Normal file
53
tui/layout.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tui
|
||||
|
||||
import "fmt"
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
func renderField(label, value string) string {
|
||||
if value == "" {
|
||||
value = "-"
|
||||
}
|
||||
return fieldLabelStyle.Render(label) + fieldValueStyle.Render(value) + "\n"
|
||||
}
|
||||
|
||||
func renderFlowField(flow string) string {
|
||||
if flow == "" {
|
||||
flow = "-"
|
||||
}
|
||||
label := fieldLabelStyle.Render("Flow")
|
||||
if flow != "writing" && flow != "-" && flow != "" {
|
||||
return label + highlightValueStyle.Render(flow) + "\n"
|
||||
}
|
||||
return label + fieldValueStyle.Render(flow) + "\n"
|
||||
}
|
||||
|
||||
func renderHighlightField(label, value string) string {
|
||||
return fieldLabelStyle.Render(label) + highlightValueStyle.Render(value) + "\n"
|
||||
}
|
||||
|
||||
func formatNumber(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
result := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
result = append(result, ',')
|
||||
}
|
||||
result = append(result, byte(c))
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
286
tui/model.go
Normal file
286
tui/model.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
const maxEvents = 500
|
||||
|
||||
type appMode int
|
||||
|
||||
const (
|
||||
modeNew appMode = iota // 等待用户输入小说需求
|
||||
modeRunning // 正在创作
|
||||
modeDone // 创作完成
|
||||
)
|
||||
|
||||
// spinner 帧序列
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// Model 是 TUI 的顶层状态。
|
||||
type Model struct {
|
||||
runtime *app.Runtime
|
||||
snapshot app.UISnapshot
|
||||
events []app.UIEvent
|
||||
viewport viewport.Model
|
||||
textarea textarea.Model
|
||||
width int
|
||||
height int
|
||||
autoScroll bool
|
||||
mode appMode
|
||||
err error
|
||||
spinnerIdx int
|
||||
}
|
||||
|
||||
// NewModel 创建 TUI Model。
|
||||
func NewModel(rt *app.Runtime) Model {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||
ta.CharLimit = 500
|
||||
ta.MaxHeight = 3
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Focus()
|
||||
|
||||
// Enter 不换行(由 Update 处理提交)
|
||||
ta.KeyMap.InsertNewline.SetEnabled(false)
|
||||
|
||||
vp := viewport.New(80, 20)
|
||||
vp.SetContent("")
|
||||
|
||||
return Model{
|
||||
runtime: rt,
|
||||
autoScroll: true,
|
||||
mode: modeNew,
|
||||
textarea: ta,
|
||||
viewport: vp,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
textarea.Blink,
|
||||
listenEvents(m.runtime),
|
||||
listenDone(m.runtime),
|
||||
tickSnapshot(m.runtime),
|
||||
checkResume(m.runtime),
|
||||
tickSpinner(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.textarea.SetWidth(m.width - 4)
|
||||
m.updateViewportSize()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEscape:
|
||||
m.textarea.Reset()
|
||||
return m, nil
|
||||
case tea.KeyCtrlL:
|
||||
m.events = nil
|
||||
m.viewport.SetContent("")
|
||||
m.viewport.GotoTop()
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
text := strings.TrimSpace(m.textarea.Value())
|
||||
if text == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.textarea.Reset()
|
||||
switch m.mode {
|
||||
case modeNew:
|
||||
m.mode = modeRunning
|
||||
m.textarea.Placeholder = "输入剧情干预,例如:把感情线提前到第4章"
|
||||
return m, startRuntime(m.runtime, text)
|
||||
case modeRunning:
|
||||
return m, steerRuntime(m.runtime, text)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyUp, tea.KeyPgUp:
|
||||
m.autoScroll = false
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
case tea.KeyDown, tea.KeyPgDown:
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case eventMsg:
|
||||
ev := app.UIEvent(msg)
|
||||
m.events = append(m.events, ev)
|
||||
if len(m.events) > maxEvents {
|
||||
m.events = m.events[len(m.events)-maxEvents:]
|
||||
}
|
||||
m.refreshEventViewport()
|
||||
return m, listenEvents(m.runtime)
|
||||
|
||||
case snapshotMsg:
|
||||
m.snapshot = app.UISnapshot(msg)
|
||||
return m, tickSnapshot(m.runtime)
|
||||
|
||||
case doneMsg:
|
||||
m.mode = modeDone
|
||||
m.textarea.Placeholder = "创作已完成"
|
||||
m.textarea.Blur()
|
||||
return m, fetchSnapshot(m.runtime)
|
||||
|
||||
case startResultMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.mode = modeNew
|
||||
m.textarea.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||
m.events = append(m.events, app.UIEvent{
|
||||
Time: time.Now(), Category: "ERROR", Summary: msg.err.Error(), Level: "error",
|
||||
})
|
||||
m.refreshEventViewport()
|
||||
} else if m.mode == modeNew {
|
||||
m.mode = modeRunning
|
||||
m.textarea.Placeholder = "输入剧情干预,例如:把感情线提前到第4章"
|
||||
}
|
||||
return m, fetchSnapshot(m.runtime)
|
||||
|
||||
case steerResultMsg:
|
||||
return m, fetchSnapshot(m.runtime)
|
||||
|
||||
case spinnerTickMsg:
|
||||
m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames)
|
||||
return m, tickSpinner()
|
||||
}
|
||||
|
||||
// 更新 textarea 组件
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// refreshEventViewport 重新渲染事件流内容并设置 viewport。
|
||||
func (m *Model) refreshEventViewport() {
|
||||
centerW := m.eventFlowWidth()
|
||||
content := renderEventContent(m.events, centerW)
|
||||
m.viewport.SetContent(content)
|
||||
if m.autoScroll {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
// updateViewportSize 根据当前窗口尺寸更新 viewport 大小。
|
||||
func (m *Model) updateViewportSize() {
|
||||
centerW := m.eventFlowWidth()
|
||||
bodyH := m.bodyHeight()
|
||||
m.viewport.Width = centerW - 2
|
||||
m.viewport.Height = bodyH
|
||||
}
|
||||
|
||||
func (m *Model) eventFlowWidth() int {
|
||||
if m.width == 0 {
|
||||
return 80
|
||||
}
|
||||
leftW := m.width * 25 / 100
|
||||
rightW := m.width * 30 / 100
|
||||
return m.width - leftW - rightW
|
||||
}
|
||||
|
||||
func (m *Model) bodyHeight() int {
|
||||
if m.height == 0 {
|
||||
return 20
|
||||
}
|
||||
topH := 1
|
||||
inputH := 3
|
||||
bodyH := m.height - topH - inputH
|
||||
if bodyH < 3 {
|
||||
bodyH = 3
|
||||
}
|
||||
return bodyH
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "加载中..."
|
||||
}
|
||||
if m.width < 100 {
|
||||
return lipgloss.NewStyle().
|
||||
Width(m.width).Height(m.height).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render("终端宽度不足,请至少扩展到 100 列")
|
||||
}
|
||||
|
||||
spinnerFrame := ""
|
||||
if m.snapshot.IsRunning {
|
||||
spinnerFrame = spinnerFrames[m.spinnerIdx]
|
||||
}
|
||||
|
||||
topBar := renderTopBar(m.snapshot, m.width, spinnerFrame)
|
||||
inputBox := renderInputBox(m.textarea.View(), m.width)
|
||||
|
||||
topH := lipgloss.Height(topBar)
|
||||
inputH := lipgloss.Height(inputBox)
|
||||
bodyH := m.height - topH - inputH
|
||||
if bodyH < 3 {
|
||||
bodyH = 3
|
||||
}
|
||||
|
||||
var body string
|
||||
if m.mode == modeNew && len(m.events) == 0 {
|
||||
errMsg := ""
|
||||
if m.err != nil {
|
||||
errMsg = m.err.Error()
|
||||
}
|
||||
body = renderWelcome(m.width, bodyH, errMsg)
|
||||
} else {
|
||||
leftW := m.width * 25 / 100
|
||||
rightW := m.width * 30 / 100
|
||||
centerW := m.width - leftW - rightW
|
||||
|
||||
if m.viewport.Width != centerW-2 || m.viewport.Height != bodyH {
|
||||
m.viewport.Width = centerW - 2
|
||||
m.viewport.Height = bodyH
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
|
||||
}
|
||||
187
tui/panels.go
Normal file
187
tui/panels.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.ModelName)
|
||||
|
||||
// 状态胶囊
|
||||
label := snap.StatusLabel
|
||||
if label == "" {
|
||||
label = "READY"
|
||||
}
|
||||
color, ok := statusColors[label]
|
||||
if !ok {
|
||||
color = colorDim
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
return topBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + capsule)
|
||||
}
|
||||
|
||||
// renderStatePanel 渲染左侧状态面板。
|
||||
func renderStatePanel(snap app.UISnapshot, width, height int) string {
|
||||
var b strings.Builder
|
||||
|
||||
if snap.RecoveryLabel != "" {
|
||||
b.WriteString(highlightValueStyle.Render("恢复: " + truncate(snap.RecoveryLabel, width-4)))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(panelTitleStyle.Render("状态"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderField("Phase", snap.Phase))
|
||||
b.WriteString(renderFlowField(snap.Flow))
|
||||
b.WriteString(renderField("Chapter", fmt.Sprintf("%d / %d", snap.CompletedCount, snap.TotalChapters)))
|
||||
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
|
||||
|
||||
if snap.InProgressChapter > 0 {
|
||||
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章 场景%d", snap.InProgressChapter, snap.CompletedScenes)))
|
||||
}
|
||||
|
||||
if len(snap.PendingRewrites) > 0 {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(panelTitleStyle.Render("返工"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHighlightField("Pending", fmt.Sprintf("%v", snap.PendingRewrites)))
|
||||
if snap.RewriteReason != "" {
|
||||
b.WriteString(renderField("Reason", truncate(snap.RewriteReason, width-12)))
|
||||
}
|
||||
}
|
||||
|
||||
if snap.PendingSteer != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(panelTitleStyle.Render("干预"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderHighlightField("Steer", truncate(snap.PendingSteer, width-12)))
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Height(height).
|
||||
Border(baseBorder, false, true, false, false).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
return style.Render(b.String())
|
||||
}
|
||||
|
||||
// renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。
|
||||
func renderEventContent(events []app.UIEvent, width int) string {
|
||||
var b strings.Builder
|
||||
for i, ev := range events {
|
||||
ts := ev.Time.Format("15:04:05")
|
||||
cat := ev.Category
|
||||
|
||||
color, ok := categoryColors[cat]
|
||||
if !ok {
|
||||
color = colorText
|
||||
}
|
||||
|
||||
catStyle := lipgloss.NewStyle().Foreground(color).Width(7)
|
||||
tsStyle := lipgloss.NewStyle().Foreground(colorDim)
|
||||
sumStyle := lipgloss.NewStyle().Foreground(color)
|
||||
|
||||
line := tsStyle.Render(ts) + " " + catStyle.Render(cat) + " " + sumStyle.Render(truncate(ev.Summary, width-20))
|
||||
b.WriteString(line)
|
||||
if i < len(events)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderEventFlowViewport 用 viewport 包装渲染事件流面板。
|
||||
func renderEventFlowViewport(vp viewport.Model, width, height int) string {
|
||||
style := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Height(height).
|
||||
Padding(0, 1)
|
||||
|
||||
return style.Render(vp.View())
|
||||
}
|
||||
|
||||
// renderDetailPanel 渲染右侧详情面板。
|
||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||
var b strings.Builder
|
||||
|
||||
if snap.LastCommitSummary != "" {
|
||||
b.WriteString(cardTitleStyle.Render("─ 最近提交 ─"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(cardContentStyle.Render(snap.LastCommitSummary))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if snap.LastReviewSummary != "" {
|
||||
b.WriteString(cardTitleStyle.Render("─ 最近审阅 ─"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(cardContentStyle.Render(snap.LastReviewSummary))
|
||||
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("\n")
|
||||
}
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Height(height).
|
||||
Border(baseBorder, false, false, false, true).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
|
||||
return style.Render(b.String())
|
||||
}
|
||||
|
||||
// renderWelcome 渲染新建态首屏。
|
||||
func renderWelcome(width, height int, errMsg string) string {
|
||||
content := lipgloss.NewStyle().Foreground(colorText).Render("还没有开始创作。") + "\n\n" +
|
||||
lipgloss.NewStyle().Foreground(colorDim).Render("请输入你的小说需求,系统会先进入设定与大纲阶段。") + "\n\n" +
|
||||
lipgloss.NewStyle().Foreground(colorAccent).Render("示例:写一部 12 章都市悬疑小说,主角是一名女法医")
|
||||
|
||||
if errMsg != "" {
|
||||
content += "\n\n" + lipgloss.NewStyle().Foreground(colorError).Bold(true).Render("错误: "+errMsg)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Height(height).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(content)
|
||||
}
|
||||
68
tui/theme.go
Normal file
68
tui/theme.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// 主题色板
|
||||
var (
|
||||
colorText = lipgloss.Color("#e0d8c8")
|
||||
colorDim = lipgloss.Color("#666666")
|
||||
colorAccent = lipgloss.Color("#d4a017") // 琥珀黄
|
||||
colorSuccess = lipgloss.Color("#2ecc71") // 冷绿
|
||||
colorError = lipgloss.Color("#e74c3c") // 朱红
|
||||
colorReview = lipgloss.Color("#e67e22") // 橙色
|
||||
)
|
||||
|
||||
// 状态标签颜色映射
|
||||
var statusColors = map[string]lipgloss.Color{
|
||||
"READY": colorDim,
|
||||
"RUNNING": colorSuccess,
|
||||
"REVIEW": colorReview,
|
||||
"REWRITE": colorReview,
|
||||
"COMPLETE": colorSuccess,
|
||||
"ERROR": colorError,
|
||||
}
|
||||
|
||||
// 事件分类颜色映射
|
||||
var categoryColors = map[string]lipgloss.Color{
|
||||
"TOOL": colorText,
|
||||
"SYSTEM": colorAccent,
|
||||
"REVIEW": colorReview,
|
||||
"CHECK": colorSuccess,
|
||||
"ERROR": colorError,
|
||||
"AGENT": colorDim,
|
||||
}
|
||||
|
||||
// 基础样式
|
||||
var (
|
||||
baseBorder = lipgloss.RoundedBorder()
|
||||
|
||||
topBarStyle = lipgloss.NewStyle().
|
||||
Foreground(colorText).
|
||||
Padding(0, 1)
|
||||
|
||||
statusCapsule = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
panelTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorAccent).
|
||||
Bold(true)
|
||||
|
||||
fieldLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim).
|
||||
Width(10)
|
||||
|
||||
fieldValueStyle = lipgloss.NewStyle().
|
||||
Foreground(colorText)
|
||||
|
||||
highlightValueStyle = lipgloss.NewStyle().
|
||||
Foreground(colorAccent).
|
||||
Bold(true)
|
||||
|
||||
cardTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorDim).
|
||||
Italic(true)
|
||||
|
||||
cardContentStyle = lipgloss.NewStyle().
|
||||
Foreground(colorText)
|
||||
)
|
||||
Reference in New Issue
Block a user