feat: support tui

This commit is contained in:
voocel
2026-03-08 12:02:46 +08:00
parent 2445560465
commit 75bdda1fe3
13 changed files with 1435 additions and 152 deletions

286
tui/model.go Normal file
View 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)
}