588 lines
14 KiB
Go
588 lines
14 KiB
Go
package tui
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"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 focusPane int
|
||
|
||
const (
|
||
focusEvents focusPane = iota
|
||
focusStream
|
||
focusDetail
|
||
)
|
||
|
||
type appMode int
|
||
|
||
const (
|
||
modeNew appMode = iota // 等待用户输入小说需求
|
||
modeRunning // 正在创作
|
||
modeDone // 创作完成
|
||
)
|
||
|
||
// 顶栏 spinner 帧序列
|
||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||
|
||
// Model 是 TUI 的顶层状态。
|
||
type Model struct {
|
||
runtime *app.Runtime
|
||
askBridge *askUserBridge
|
||
askState *askUserState
|
||
snapshot app.UISnapshot
|
||
events []app.UIEvent
|
||
viewport viewport.Model // 事件流 viewport
|
||
streamVP viewport.Model // 流式输出 viewport
|
||
detailVP viewport.Model // 右侧详情 viewport
|
||
streamBuf *strings.Builder // 流式文本累积缓冲
|
||
streamRounds []string
|
||
textarea textarea.Model
|
||
width int
|
||
height int
|
||
autoScroll bool
|
||
streamScroll bool // 流式面板自动跟随
|
||
focusPane focusPane
|
||
hoverPane focusPane
|
||
hoverActive bool
|
||
mode appMode
|
||
err error
|
||
spinnerIdx int
|
||
streamRound int // 流式输出轮次计数
|
||
}
|
||
|
||
// NewModel 创建 TUI Model。
|
||
func NewModel(rt *app.Runtime, bridge *askUserBridge) Model {
|
||
ta := textarea.New()
|
||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||
ta.CharLimit = 500
|
||
ta.SetHeight(1)
|
||
ta.MaxHeight = 1
|
||
ta.ShowLineNumbers = false
|
||
ta.Focus()
|
||
|
||
// Enter 不换行(由 Update 处理提交)
|
||
ta.KeyMap.InsertNewline.SetEnabled(false)
|
||
|
||
vp := viewport.New(80, 20)
|
||
vp.SetContent("")
|
||
|
||
svp := viewport.New(80, 10)
|
||
svp.SetContent("")
|
||
|
||
dvp := viewport.New(40, 20)
|
||
dvp.SetContent("")
|
||
|
||
return Model{
|
||
runtime: rt,
|
||
askBridge: bridge,
|
||
autoScroll: true,
|
||
streamScroll: true,
|
||
mode: modeNew,
|
||
textarea: ta,
|
||
viewport: vp,
|
||
streamVP: svp,
|
||
detailVP: dvp,
|
||
streamBuf: &strings.Builder{},
|
||
}
|
||
}
|
||
|
||
func (m Model) Init() tea.Cmd {
|
||
return tea.Batch(
|
||
textarea.Blink,
|
||
listenEvents(m.runtime),
|
||
listenAskUser(m.askBridge),
|
||
listenDone(m.runtime),
|
||
listenStream(m.runtime),
|
||
listenStreamClear(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.inputWidth())
|
||
m.updateViewportSize()
|
||
m.refreshDetailViewport()
|
||
return m, nil
|
||
|
||
case tea.KeyMsg:
|
||
if m.askState != nil {
|
||
return m.handleAskUserKey(msg)
|
||
}
|
||
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()
|
||
m.streamBuf.Reset()
|
||
m.streamRounds = nil
|
||
m.streamVP.SetContent("")
|
||
m.streamVP.GotoTop()
|
||
m.streamRound = 0
|
||
return m, nil
|
||
case tea.KeyTab:
|
||
m.focusPane = (m.focusPane + 1) % 3
|
||
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:
|
||
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.focusPane == focusStream {
|
||
var cmd tea.Cmd
|
||
m.streamVP, cmd = m.streamVP.Update(msg)
|
||
if m.streamVP.AtBottom() {
|
||
m.streamScroll = true
|
||
}
|
||
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() {
|
||
m.autoScroll = true
|
||
}
|
||
return m, cmd
|
||
case tea.KeyEnd:
|
||
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()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
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.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 {
|
||
m.autoScroll = m.viewport.AtBottom()
|
||
}
|
||
}
|
||
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 askUserMsg:
|
||
m.askState = newAskUserState(askUserRequest(msg))
|
||
m.textarea.Blur()
|
||
m.events = append(m.events, app.UIEvent{
|
||
Time: time.Now(), Category: "SYSTEM", Summary: "等待用户补充关键信息", Level: "info",
|
||
})
|
||
m.refreshEventViewport()
|
||
return m, listenAskUser(m.askBridge)
|
||
|
||
case snapshotMsg:
|
||
m.snapshot = app.UISnapshot(msg)
|
||
m.refreshDetailViewport()
|
||
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)
|
||
if m.snapshot.IsRunning {
|
||
m.refreshEventViewport()
|
||
}
|
||
return m, tickSpinner()
|
||
|
||
case streamDeltaMsg:
|
||
if len(m.streamRounds) == 0 {
|
||
m.streamRounds = append(m.streamRounds, "")
|
||
}
|
||
m.streamRounds[len(m.streamRounds)-1] += string(msg)
|
||
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
|
||
if m.streamScroll {
|
||
m.streamVP.GotoBottom()
|
||
}
|
||
return m, listenStream(m.runtime)
|
||
|
||
case streamClearMsg:
|
||
// 新一轮输出:按轮次分块显示,避免长文本和分隔线直接拼接导致错乱。
|
||
if len(m.streamRounds) == 0 {
|
||
m.streamRounds = append(m.streamRounds, "")
|
||
} else if strings.TrimSpace(m.streamRounds[len(m.streamRounds)-1]) != "" {
|
||
m.streamRounds = append(m.streamRounds, "")
|
||
}
|
||
m.streamRound = len(m.streamRounds)
|
||
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
|
||
if m.streamScroll {
|
||
m.streamVP.GotoBottom()
|
||
}
|
||
return m, listenStreamClear(m.runtime)
|
||
}
|
||
|
||
// 更新 textarea 组件
|
||
var cmd tea.Cmd
|
||
m.textarea, cmd = m.textarea.Update(msg)
|
||
cmds = append(cmds, 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()
|
||
content := renderEventContent(m.events, centerW)
|
||
if m.snapshot.IsRunning {
|
||
content += renderSparkle(m.spinnerIdx)
|
||
}
|
||
m.viewport.SetContent(content)
|
||
if m.autoScroll {
|
||
m.viewport.GotoBottom()
|
||
}
|
||
}
|
||
|
||
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 计算事件流和流式输出的高度分配。
|
||
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
|
||
}
|
||
return m.width - 6 // border + padding + 提示符 "❯ "
|
||
}
|
||
|
||
func (m *Model) eventFlowWidth() int {
|
||
if m.width == 0 {
|
||
return 80
|
||
}
|
||
leftW := m.width * 25 / 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
|
||
}
|
||
topH := 1
|
||
inputH := 6 // top border + 输入行 + bottom border + 空行 + 提示行 + \n
|
||
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%len(spinnerFrames)]
|
||
}
|
||
|
||
topBar := renderTopBar(m.snapshot, m.width, spinnerFrame)
|
||
inputBox := renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), 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.detailWidth()
|
||
centerW := m.width - leftW - rightW
|
||
eventH, streamH := m.splitHeights(bodyH)
|
||
|
||
if m.viewport.Width != centerW-2 || m.viewport.Height != eventH-1 {
|
||
m.viewport.Width = centerW - 2
|
||
m.viewport.Height = eventH - 1 // -1 为 event panel header 行
|
||
}
|
||
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, 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.detailVP, rightW, bodyH, m.paneHighlighted(focusDetail))
|
||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
||
}
|
||
|
||
view := lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
|
||
if m.askState != nil {
|
||
return renderAskUserModal(m.width, m.height, m.askState)
|
||
}
|
||
return view
|
||
}
|
||
|
||
func (m Model) handleAskUserKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
if m.askState == nil {
|
||
return m, nil
|
||
}
|
||
state := m.askState
|
||
q := state.currentQuestion()
|
||
|
||
if state.typing {
|
||
switch msg.Type {
|
||
case tea.KeyEsc:
|
||
state.cancelCurrentTyping()
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
if state.finishCurrentAnswer() {
|
||
state.submit()
|
||
m.askState = nil
|
||
if m.mode != modeDone {
|
||
m.textarea.Focus()
|
||
}
|
||
}
|
||
return m, nil
|
||
case tea.KeyBackspace, tea.KeyCtrlH:
|
||
if state.input != "" {
|
||
_, size := utf8.DecodeLastRuneInString(state.input)
|
||
state.input = state.input[:len(state.input)-size]
|
||
}
|
||
return m, nil
|
||
default:
|
||
if msg.Type == tea.KeyRunes {
|
||
state.input += string(msg.Runes)
|
||
}
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
switch msg.Type {
|
||
case tea.KeyUp:
|
||
state.moveCursor(-1)
|
||
case tea.KeyDown:
|
||
state.moveCursor(1)
|
||
case tea.KeySpace:
|
||
if q.MultiSelect {
|
||
state.toggleSelection()
|
||
if state.cursor == len(q.Options) && !state.selected[state.cursor] {
|
||
state.input = ""
|
||
}
|
||
}
|
||
case tea.KeyEnter:
|
||
if q.MultiSelect {
|
||
if state.cursor == len(q.Options) {
|
||
state.toggleSelection()
|
||
if state.selected[state.cursor] {
|
||
state.typing = true
|
||
}
|
||
return m, nil
|
||
}
|
||
if len(state.selected) == 0 {
|
||
state.toggleSelection()
|
||
}
|
||
}
|
||
if state.finishCurrentAnswer() {
|
||
state.submit()
|
||
m.askState = nil
|
||
if m.mode != modeDone {
|
||
m.textarea.Focus()
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|