feat: rhythm tracking and structured review
This commit is contained in:
59
tui/input.go
59
tui/input.go
@@ -9,55 +9,60 @@ import (
|
||||
"github.com/voocel/ainovel-cli/app"
|
||||
)
|
||||
|
||||
// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。
|
||||
// renderInputBox 渲染底部栏(两行布局)。
|
||||
// 第一行:❯ + 输入框
|
||||
// 第二行:左快捷键提示,右进度信息
|
||||
func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string {
|
||||
// 左侧:快捷键提示
|
||||
keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc")
|
||||
innerW := width - 4 // border + padding
|
||||
|
||||
// 右侧:进度 + 输出目录
|
||||
right := buildRightInfo(snap, outputDir)
|
||||
// 第一行:提示符 + 输入框
|
||||
prompt := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("❯ ")
|
||||
line1 := prompt + inputView
|
||||
|
||||
// 中间:输入框,自适应宽度
|
||||
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
|
||||
// 第二行:左快捷键,右进度
|
||||
hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送")
|
||||
info := buildRightInfo(snap, outputDir)
|
||||
|
||||
hintsW := lipgloss.Width(hints)
|
||||
infoW := lipgloss.Width(info)
|
||||
gap := innerW - hintsW - infoW
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
line2 := hints + strings.Repeat(" ", gap) + info
|
||||
|
||||
input := lipgloss.NewStyle().Width(inputW).Render(inputView)
|
||||
|
||||
content := keys + sep + input + sep + right
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
// 输入区(上横线 + 输入行)
|
||||
inputStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Border(baseBorder, true, false, false, false).
|
||||
Border(baseBorder, true, false, true, false).
|
||||
BorderForeground(colorDim).
|
||||
Padding(0, 1)
|
||||
inputBlock := inputStyle.Render(line1)
|
||||
|
||||
return style.Render(content)
|
||||
// 提示行(无边框,紧贴下横线下方)
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Padding(0, 2)
|
||||
hintBlock := hintStyle.Render(line2)
|
||||
|
||||
return inputBlock + "\n" + hintBlock + "\n"
|
||||
}
|
||||
|
||||
// buildRightInfo 构建右侧进度和目录信息。
|
||||
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
||||
var parts []string
|
||||
|
||||
// 章节进度
|
||||
if snap.ModelName != "" {
|
||||
parts = append(parts, snap.ModelName)
|
||||
}
|
||||
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)
|
||||
parts = append(parts, "./"+filepath.Base(outputDir))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
|
||||
33
tui/model.go
33
tui/model.go
@@ -31,7 +31,7 @@ type Model struct {
|
||||
events []app.UIEvent
|
||||
viewport viewport.Model // 事件流 viewport
|
||||
streamVP viewport.Model // 流式输出 viewport
|
||||
streamBuf strings.Builder // 流式文本累积缓冲
|
||||
streamBuf *strings.Builder // 流式文本累积缓冲
|
||||
textarea textarea.Model
|
||||
width int
|
||||
height int
|
||||
@@ -41,6 +41,7 @@ type Model struct {
|
||||
mode appMode
|
||||
err error
|
||||
spinnerIdx int
|
||||
streamRound int // 流式输出轮次计数
|
||||
}
|
||||
|
||||
// NewModel 创建 TUI Model。
|
||||
@@ -48,6 +49,7 @@ func NewModel(rt *app.Runtime) Model {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||
ta.CharLimit = 500
|
||||
ta.SetHeight(1)
|
||||
ta.MaxHeight = 1
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Focus()
|
||||
@@ -69,6 +71,7 @@ func NewModel(rt *app.Runtime) Model {
|
||||
textarea: ta,
|
||||
viewport: vp,
|
||||
streamVP: svp,
|
||||
streamBuf: &strings.Builder{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.streamBuf.Reset()
|
||||
m.streamVP.SetContent("")
|
||||
m.streamVP.GotoTop()
|
||||
m.streamRound = 0
|
||||
return m, nil
|
||||
case tea.KeyTab:
|
||||
m.focusStream = !m.focusStream
|
||||
@@ -234,10 +238,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, listenStream(m.runtime)
|
||||
|
||||
case streamClearMsg:
|
||||
m.streamBuf.Reset()
|
||||
m.streamVP.SetContent("")
|
||||
m.streamVP.GotoTop()
|
||||
m.streamScroll = true
|
||||
// 新一轮输出:保留历史内容,用分隔线标记新段落
|
||||
m.streamRound++
|
||||
if m.streamBuf.Len() > 0 {
|
||||
m.streamBuf.WriteString("\n")
|
||||
m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width))
|
||||
m.streamBuf.WriteString("\n")
|
||||
}
|
||||
m.streamVP.SetContent(m.streamBuf.String())
|
||||
if m.streamScroll {
|
||||
m.streamVP.GotoBottom()
|
||||
}
|
||||
return m, listenStreamClear(m.runtime)
|
||||
}
|
||||
|
||||
@@ -290,15 +301,7 @@ 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
|
||||
return m.width - 6 // border + padding + 提示符 "❯ "
|
||||
}
|
||||
|
||||
func (m *Model) eventFlowWidth() int {
|
||||
@@ -315,7 +318,7 @@ func (m *Model) bodyHeight() int {
|
||||
return 20
|
||||
}
|
||||
topH := 1
|
||||
inputH := 2 // 单行输入 + top border
|
||||
inputH := 6 // top border + 输入行 + bottom border + 空行 + 提示行 + \n
|
||||
bodyH := m.height - topH - inputH
|
||||
if bodyH < 3 {
|
||||
bodyH = 3
|
||||
|
||||
@@ -208,6 +208,19 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin
|
||||
return header + "\n" + vpStyle.Render(vp.View())
|
||||
}
|
||||
|
||||
// renderStreamSeparator 渲染流式面板中的轮次分隔线。
|
||||
func renderStreamSeparator(round, width int) string {
|
||||
label := fmt.Sprintf(" #%d ", round)
|
||||
lineW := (width - lipgloss.Width(label)) / 2
|
||||
if lineW < 1 {
|
||||
lineW = 1
|
||||
}
|
||||
line := strings.Repeat("─", lineW)
|
||||
dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line)
|
||||
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label)
|
||||
return dimLine + dimLabel + dimLine
|
||||
}
|
||||
|
||||
// renderDetailPanel 渲染右侧详情面板。
|
||||
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||
|
||||
Reference in New Issue
Block a user