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 }