feat: add tab pane

This commit is contained in:
voocel
2026-03-11 19:03:33 +08:00
parent 74a8c8eaef
commit e9c8220bc3
10 changed files with 250 additions and 34 deletions

View File

@@ -20,7 +20,7 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
line1 := prompt + inputView
// 第二行:左快捷键,右进度
hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送")
hints := lipgloss.NewStyle().Foreground(colorDim).Render("点击/Tab 切换面板 · ↑↓ 滚动 · End 跳底 · ^L 清屏 · Esc 重置 · Enter 发送")
info := buildRightInfo(snap, outputDir)
hintsW := lipgloss.Width(hints)
@@ -52,6 +52,9 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
var parts []string
if snap.Provider != "" {
parts = append(parts, snap.Provider)
}
if snap.ModelName != "" {
parts = append(parts, snap.ModelName)
}

View File

@@ -13,6 +13,14 @@ import (
const maxEvents = 500
type focusPane int
const (
focusEvents focusPane = iota
focusStream
focusDetail
)
type appMode int
const (
@@ -29,15 +37,18 @@ type Model struct {
runtime *app.Runtime
snapshot app.UISnapshot
events []app.UIEvent
viewport viewport.Model // 事件流 viewport
streamVP viewport.Model // 流式输出 viewport
viewport viewport.Model // 事件流 viewport
streamVP viewport.Model // 流式输出 viewport
detailVP viewport.Model // 右侧详情 viewport
streamBuf *strings.Builder // 流式文本累积缓冲
textarea textarea.Model
width int
height int
autoScroll bool
streamScroll bool // 流式面板自动跟随
focusStream bool // true=焦点在流式面板, false=事件流
focusPane focusPane
hoverPane focusPane
hoverActive bool
mode appMode
err error
spinnerIdx int
@@ -63,6 +74,9 @@ func NewModel(rt *app.Runtime) Model {
svp := viewport.New(80, 10)
svp.SetContent("")
dvp := viewport.New(40, 20)
dvp.SetContent("")
return Model{
runtime: rt,
autoScroll: true,
@@ -71,6 +85,7 @@ func NewModel(rt *app.Runtime) Model {
textarea: ta,
viewport: vp,
streamVP: svp,
detailVP: dvp,
streamBuf: &strings.Builder{},
}
}
@@ -97,6 +112,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.textarea.SetWidth(m.inputWidth())
m.updateViewportSize()
m.refreshDetailViewport()
return m, nil
case tea.KeyMsg:
@@ -116,7 +132,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamRound = 0
return m, nil
case tea.KeyTab:
m.focusStream = !m.focusStream
m.focusPane = (m.focusPane + 1) % 3
return m, nil
case tea.KeyEnter:
text := strings.TrimSpace(m.textarea.Value())
@@ -134,18 +150,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyUp, tea.KeyPgUp:
if m.focusStream {
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.focusStream {
if m.focusPane == focusStream {
var cmd tea.Cmd
m.streamVP, cmd = m.streamVP.Update(msg)
if m.streamVP.AtBottom() {
@@ -153,6 +174,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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() {
@@ -160,9 +186,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
case tea.KeyEnd:
if m.focusStream {
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()
@@ -171,12 +199,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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.focusStream {
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 {
@@ -196,6 +235,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case snapshotMsg:
m.snapshot = app.UISnapshot(msg)
m.refreshDetailViewport()
return m, tickSnapshot(m.runtime)
case doneMsg:
@@ -260,6 +300,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.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()
@@ -273,15 +357,26 @@ func (m *Model) refreshEventViewport() {
}
}
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 计算事件流和流式输出的高度分配。
@@ -309,10 +404,17 @@ func (m *Model) eventFlowWidth() int {
return 80
}
leftW := m.width * 25 / 100
rightW := m.width * 30 / 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
@@ -362,7 +464,7 @@ func (m Model) View() string {
body = renderWelcome(m.width, bodyH, errMsg)
} else {
leftW := m.width * 25 / 100
rightW := m.width * 30 / 100
rightW := m.detailWidth()
centerW := m.width - leftW - rightW
eventH, streamH := m.splitHeights(bodyH)
@@ -375,12 +477,12 @@ func (m Model) View() string {
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
}
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream)
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
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.snapshot, rightW, bodyH)
right := renderDetailPanel(m.detailVP, rightW, bodyH, m.paneHighlighted(focusDetail))
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
}

View File

@@ -27,6 +27,9 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
// 第二行左侧:模型 + 风格
var infoParts []string
if snap.Provider != "" {
infoParts = append(infoParts, snap.Provider)
}
if snap.ModelName != "" {
infoParts = append(infoParts, snap.ModelName)
}
@@ -238,10 +241,9 @@ func renderStreamSeparator(round, width int) string {
return dimLine + dimLabel + dimLine
}
// renderDetailPanel 渲染右侧详情面板。
// renderDetailContent 构建右侧详情面板内容
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
contentW := width - 4 // 边框 + padding
func renderDetailContent(snap app.UISnapshot, contentW int) string {
var b strings.Builder
// 大纲
@@ -309,14 +311,23 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string {
}
}
return b.String()
}
// renderDetailPanel 渲染右侧可滚动详情面板。
func renderDetailPanel(vp viewport.Model, width, height int, focused bool) string {
borderColor := colorDim
if focused {
borderColor = colorAccent
}
style := lipgloss.NewStyle().
Width(width).
Height(height).
Border(baseBorder, false, false, false, true).
BorderForeground(colorDim).
BorderForeground(borderColor).
Padding(0, 1)
return style.Render(b.String())
return style.Render(vp.View())
}
// renderWelcome 渲染新建态首屏。