Skip to content

Commit fbc390a

Browse files
committed
feat(tui-v2): 实现 Agent Stream 行为流组件与智能滚动引擎 (Phase 8)
本次提交完成了 TUI v2 Phase 8 的核心开发,成功实现了 Ghost Console 的视觉中心 `AgentStream` 组件。将底层的不可变事件流精确映射为高可读的终端原生行为流,并完整构建了平滑的自动/手动滚动接管机制。 主要改动与实现: - 多态流式渲染:完整支持 8 种 `StreamEntry` 类型(`message`、`tool_*`、`permission`、`question`、`status`、`error`)的专属渲染分支。精准匹配不同的前缀符号(如 ◉, ✓, ×, ◌)与层级缩进,重现清爽的极客控制台风格。 - 智能滚动状态机:在 `LayoutState` 中新增 `ScrollOffset` 与 `AutoScroll`。实现默认自动跟随最新消息;支持按键 (`k/up`, `g`) 手动上滚并自动挂起跟随;在滚回底部 (`j/down`, `G`) 或接收到新的 Gateway 增量事件时,无缝恢复自动跟随底部。 - 终端重绘防撕裂:全面接入宽字符安全的定宽截断逻辑,严格执行 `width - 1` 的单行渲染宽度上限。彻底弃用 `lipgloss.Width()`,从根本上杜绝了终端 Resize 时触发 Pending Wrap 所导致的残影问题。 架构与视觉合规性: - 视觉纪律捍卫:组件内部实现了“零样式硬编码”。一切色彩、状态前缀均由 `theme` 包统一分发。 - 红线扫描:经严苛的 `rg` 扫描确认,本次提交绝对没有使用任何违规的边框 (`Border`) 或圆角背景,亦未跨层引用任何后端业务库。 验证结果: - 单元测试与 1000+ 条 entry 的虚拟渲染性能测试全部通过。 - 本地启动 `--scenario=long_output` 与 `--scenario=streaming_chat` 场景,上下滚动拦截与状态机切分逻辑顺畅,界面刷新稳定。
1 parent 05777e3 commit fbc390a

4 files changed

Lines changed: 423 additions & 34 deletions

File tree

internal/tuiv2/app.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8080
a.applyWindowSize(msg.Width, msg.Height)
8181
return a, tea.ClearScreen
8282
case tea.KeyMsg:
83+
if handled, cmd := a.routeStreamKey(msg); handled {
84+
return a, cmd
85+
}
8386
switch msg.String() {
8487
case "ctrl+c", "esc", "q":
8588
return a, tea.Quit
@@ -93,7 +96,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9396
if msg.closed {
9497
return a, nil
9598
}
99+
beforeStreamLen := len(a.state.Stream)
96100
a.state = state.Reduce(a.state, msg.event)
101+
if len(a.state.Stream) > beforeStreamLen {
102+
a.state.Layout.AutoScroll = true
103+
a.state.Layout.ScrollOffset = 0
104+
}
97105
a.bindComponents()
98106
if a.state.Runtime.Phase == state.RuntimePhaseError && len(a.state.Stream) > 0 {
99107
a.lastErr = a.state.Stream[len(a.state.Stream)-1].Content
@@ -145,6 +153,17 @@ func (a *App) routeComponents(msg tea.Msg) tea.Cmd {
145153
return tea.Batch(statusCmd, streamCmd, inspectorCmd, promptCmd)
146154
}
147155

156+
// routeStreamKey 将滚动按键优先交给 Agent Stream,避免与全局快捷键混淆。
157+
func (a *App) routeStreamKey(msg tea.KeyMsg) (bool, tea.Cmd) {
158+
switch msg.String() {
159+
case "j", "down", "k", "up", "g", "G":
160+
_, cmd := a.agentStream.Update(msg)
161+
return true, cmd
162+
default:
163+
return false, nil
164+
}
165+
}
166+
148167
// mainArea 渲染中部区域,按终端宽度决定 Inspector 右侧或纵向压缩显示。
149168
func (a *App) mainArea() string {
150169
streamView := a.agentStream.View()

internal/tuiv2/components/stream.go

Lines changed: 260 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@ package components
33
import (
44
"fmt"
55
"strings"
6+
"time"
67

78
tea "github.com/charmbracelet/bubbletea"
9+
810
"neo-code/internal/tuiv2/state"
911
"neo-code/internal/tuiv2/theme"
1012
)
1113

14+
const (
15+
streamHeaderRows = 1
16+
streamReservedRows = 7
17+
streamTimeGap = 5 * time.Minute
18+
streamVirtualOverscan = 20
19+
)
20+
1221
// AgentStream 渲染 Agent 行为流,包括消息、工具调用和状态条目。
1322
type AgentStream struct {
1423
state *state.ViewState
@@ -26,30 +35,58 @@ func (c *AgentStream) Init() tea.Cmd {
2635
return nil
2736
}
2837

29-
// Update 当前不维护组件私有业务状态,只保留 tea.Model 契约
38+
// Update 处理 Agent Stream 的滚动按键,不维护冗余业务状态
3039
func (c *AgentStream) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
40+
key, ok := msg.(tea.KeyMsg)
41+
if !ok {
42+
return c, nil
43+
}
44+
maxOffset := c.maxScrollOffset()
45+
switch key.String() {
46+
case "k", "up":
47+
c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset+1, maxOffset)
48+
c.state.Layout.AutoScroll = false
49+
case "j", "down":
50+
c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset-1, maxOffset)
51+
c.state.Layout.AutoScroll = c.state.Layout.ScrollOffset == 0
52+
case "g":
53+
c.state.Layout.ScrollOffset = maxOffset
54+
c.state.Layout.AutoScroll = false
55+
case "G":
56+
c.state.Layout.ScrollOffset = 0
57+
c.state.Layout.AutoScroll = true
58+
}
3159
return c, nil
3260
}
3361

34-
// View 渲染 Agent Stream,占位阶段用缩进和状态符号表达层级
62+
// View 渲染 Agent Stream,按滚动窗口选择可见条目并进行宽度安全截断
3563
func (c *AgentStream) View() string {
3664
width := c.streamWidth()
37-
lines := []string{theme.MutedStyle().Render("Agent Stream")}
38-
if len(c.state.Stream) == 0 {
39-
lines = append(lines, theme.AccentStyle().Render(" "+theme.StatusSymbol(theme.PhaseRunning)+" 我可以帮你做什么?"))
40-
lines = append(lines, theme.MutedStyle().Render(" "+theme.StatusSymbol(theme.PhaseIdle)+" "+surfaceName))
41-
} else {
42-
for _, entry := range tailEntries(c.state.Stream, c.visibleEntries()) {
43-
lines = append(lines, c.renderEntry(entry))
65+
lines := []string{theme.MutedStyle().Render(c.headerText())}
66+
rendered := c.renderAllEntries()
67+
if len(rendered) == 0 {
68+
rendered = []string{
69+
theme.AccentStyle().Render(" " + theme.StatusSymbol(theme.PhaseRunning) + " 我可以帮你做什么?"),
70+
theme.MutedStyle().Render(" " + theme.StatusSymbol(theme.PhaseIdle) + " " + surfaceName),
4471
}
4572
}
73+
lines = append(lines, c.visibleLines(rendered)...)
4674
content := strings.Join(lines, "\n")
4775
if width > 0 {
4876
return fitBlock(content, width, true)
4977
}
5078
return content
5179
}
5280

81+
// headerText 渲染 Stream 标题,并在手动滚动时显示偏移量。
82+
func (c *AgentStream) headerText() string {
83+
if c.state.Layout.AutoScroll {
84+
return "Agent Stream"
85+
}
86+
return fmt.Sprintf("Agent Stream scroll:%d", c.state.Layout.ScrollOffset)
87+
}
88+
89+
// streamWidth 根据布局断点计算 Agent Stream 可用宽度。
5390
// streamWidth 根据布局断点计算 Agent Stream 可用宽度。
5491
func (c *AgentStream) streamWidth() int {
5592
width := c.state.Layout.Width
@@ -59,48 +96,237 @@ func (c *AgentStream) streamWidth() int {
5996
return width
6097
}
6198

62-
// visibleEntries 根据终端高度估算可展示的流条目数量。
63-
func (c *AgentStream) visibleEntries() int {
99+
// visibleLineCount 根据终端高度估算可展示的流行数。
100+
// visibleLineCount 根据终端高度估算可展示的流行数。
101+
func (c *AgentStream) visibleLineCount() int {
64102
height := c.state.Layout.Height
65103
if height <= 0 {
66104
return 8
67105
}
68-
limit := height - 8
106+
limit := height - streamReservedRows - streamHeaderRows
69107
if limit < 4 {
70108
return 4
71109
}
72110
return limit
73111
}
74112

75-
// renderEntry 将单条 StreamEntry 渲染为带状态符号的文本行。
76-
func (c *AgentStream) renderEntry(entry state.StreamEntry) string {
77-
content := entry.Content
78-
if content == "" {
79-
content = stringOrDash(entry.Type)
113+
// maxScrollOffset 计算当前渲染内容允许的最大手动滚动偏移。
114+
func (c *AgentStream) maxScrollOffset() int {
115+
lines := len(c.renderAllEntries())
116+
visible := c.visibleLineCount()
117+
if lines <= visible {
118+
return 0
119+
}
120+
return lines - visible
121+
}
122+
123+
// visibleLines 根据滚动状态截取最终可见行。
124+
func (c *AgentStream) visibleLines(lines []string) []string {
125+
visible := c.visibleLineCount()
126+
if len(lines) <= visible {
127+
c.state.Layout.ScrollOffset = 0
128+
return lines
129+
}
130+
maxOffset := len(lines) - visible
131+
if c.state.Layout.AutoScroll {
132+
c.state.Layout.ScrollOffset = 0
80133
}
134+
c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset, maxOffset)
135+
end := len(lines) - c.state.Layout.ScrollOffset
136+
start := end - visible
137+
if start < 0 {
138+
start = 0
139+
}
140+
return lines[start:end]
141+
}
142+
143+
// renderAllEntries 将 StreamEntry 序列转换为完整的待裁剪行集合。
144+
func (c *AgentStream) renderAllEntries() []string {
145+
entries := c.virtualEntries()
146+
lines := make([]string, 0, len(entries)*2)
147+
var previous *state.StreamEntry
148+
for index := range entries {
149+
entry := entries[index]
150+
if shouldSeparate(previous, &entry) && len(lines) > 0 {
151+
lines = append(lines, "")
152+
}
153+
if shouldShowTimestamp(previous, &entry) {
154+
lines = append(lines, c.renderTimestamp(entry.Timestamp))
155+
}
156+
lines = append(lines, c.renderEntry(entry)...)
157+
previous = &entry
158+
}
159+
return lines
160+
}
161+
162+
// virtualEntries 为超长 stream 预留虚拟化窗口,避免每次渲染处理全部历史。
163+
func (c *AgentStream) virtualEntries() []state.StreamEntry {
164+
entries := c.state.Stream
165+
if len(entries) <= 1000 {
166+
return entries
167+
}
168+
visible := c.visibleLineCount() + streamVirtualOverscan*2
169+
if visible > len(entries) {
170+
visible = len(entries)
171+
}
172+
end := len(entries) - c.state.Layout.ScrollOffset
173+
if end > len(entries) {
174+
end = len(entries)
175+
}
176+
if end < visible {
177+
end = visible
178+
}
179+
start := end - visible
180+
if start < 0 {
181+
start = 0
182+
}
183+
return entries[start:end]
184+
}
185+
186+
// renderEntry 将单条 StreamEntry 渲染为一种或多种 Ghost Console 行。
187+
func (c *AgentStream) renderEntry(entry state.StreamEntry) []string {
81188
switch entry.Type {
82-
case "tool_started", "tool_start":
83-
return theme.AccentStyle().Render(" "+theme.StreamPrefix(entry.Type)+" tool."+stringOrDash(entry.ToolName)) +
84-
theme.MutedStyle().Render(" "+theme.Separator()+" "+content)
85-
case "tool_finished", "tool_end":
86-
return theme.SuccessStyle().Render(" "+theme.StreamPrefix(entry.Type)+" tool."+stringOrDash(entry.ToolName)) +
87-
theme.MutedStyle().Render(" "+theme.Separator()+" "+content)
88-
case "permission_requested", "question":
89-
return theme.AccentStyle().Render(" "+theme.StreamPrefix(entry.Type)+" "+entry.Type) +
90-
theme.MutedStyle().Render(" "+theme.Separator()+" "+content)
91-
case "error", "gateway_offline":
92-
return theme.ErrorStyle().Render(" " + theme.StreamPrefix(entry.Type) + " " + content)
189+
case "message":
190+
return c.renderMessage(entry)
191+
case "tool_start":
192+
return c.renderToolStart(entry)
193+
case "tool_end":
194+
return c.renderToolEnd(entry)
195+
case "tool_output":
196+
return c.renderToolOutput(entry)
197+
case "permission", "permission_requested":
198+
return c.renderPermission(entry)
199+
case "question", "ask_user_question", "user_question_requested":
200+
return c.renderQuestion(entry)
201+
case "status", "run_started", "run_finished", "run_cancelled", "phase_changed", "session_updated", "model_changed", "health_changed":
202+
return c.renderStatus(entry)
203+
case "error", "run_error", "gateway_offline":
204+
return c.renderError(entry)
93205
default:
94-
return theme.MutedStyle().Render(fmt.Sprintf(" %s %s", theme.StreamPrefix(entry.Type), content))
206+
return c.renderStatus(entry)
95207
}
96208
}
97209

98-
// tailEntries 返回最近的流条目,避免静态布局在小终端中过长。
99-
func tailEntries(entries []state.StreamEntry, limit int) []state.StreamEntry {
100-
if len(entries) <= limit {
101-
return entries
210+
// renderMessage 渲染普通消息正文,支持换行。
211+
func (c *AgentStream) renderMessage(entry state.StreamEntry) []string {
212+
text := entry.Content
213+
if text == "" {
214+
text = "-"
102215
}
103-
return entries[len(entries)-limit:]
216+
return renderWrappedLines(text, "", theme.BaseStyle())
217+
}
218+
219+
// renderToolStart 渲染工具调用开始行。
220+
func (c *AgentStream) renderToolStart(entry state.StreamEntry) []string {
221+
toolName := stringOrDash(entry.ToolName)
222+
content := entry.Content
223+
if content == "" {
224+
content = entry.ToolInput
225+
}
226+
line := theme.AccentStyle().Render(" "+theme.StreamPrefix("tool_start")+" tool."+toolName) +
227+
theme.MutedStyle().Render(" "+theme.Separator()+" ") +
228+
renderToolContent(content)
229+
return []string{line}
230+
}
231+
232+
// renderToolEnd 渲染工具调用完成行。
233+
func (c *AgentStream) renderToolEnd(entry state.StreamEntry) []string {
234+
line := theme.SuccessStyle().Render(" " + theme.StreamPrefix("tool_end") + " tool." + stringOrDash(entry.ToolName))
235+
if entry.Content != "" {
236+
line += theme.MutedStyle().Render(" " + theme.Separator() + " " + entry.Content)
237+
}
238+
return []string{line}
239+
}
240+
241+
// renderToolOutput 渲染工具输出内容,使用缩进指示条。
242+
func (c *AgentStream) renderToolOutput(entry state.StreamEntry) []string {
243+
content := entry.Content
244+
if content == "" {
245+
content = "-"
246+
}
247+
return renderWrappedLines(content, " "+theme.AccentBar()+" ", theme.CodeBlockStyle())
248+
}
249+
250+
// renderPermission 渲染权限请求状态行。
251+
func (c *AgentStream) renderPermission(entry state.StreamEntry) []string {
252+
return []string{theme.WarningStyle().Render(" " + theme.StreamPrefix("permission_requested") + " " + stringOrDash(entry.Content))}
253+
}
254+
255+
// renderQuestion 渲染 ask_user 问题行。
256+
func (c *AgentStream) renderQuestion(entry state.StreamEntry) []string {
257+
return []string{theme.MutedStyle().Render(" " + theme.Separator() + " " + stringOrDash(entry.Content))}
258+
}
259+
260+
// renderStatus 渲染普通状态变更行。
261+
func (c *AgentStream) renderStatus(entry state.StreamEntry) []string {
262+
return []string{theme.MutedStyle().Render(" " + theme.StreamPrefix(entry.Type) + " " + stringOrDash(entry.Content))}
263+
}
264+
265+
// renderError 渲染错误状态行。
266+
func (c *AgentStream) renderError(entry state.StreamEntry) []string {
267+
return []string{theme.ErrorStyle().Render(" " + theme.StreamPrefix("error") + " " + stringOrDash(entry.Content))}
268+
}
269+
270+
// renderTimestamp 渲染长时间间隔分隔时间戳。
271+
func (c *AgentStream) renderTimestamp(timestamp time.Time) string {
272+
if timestamp.IsZero() {
273+
return ""
274+
}
275+
return theme.TimestampStyle().Render(" " + timestamp.Format("15:04"))
276+
}
277+
278+
// renderWrappedLines 按内容换行拆分并为每行添加前缀和样式。
279+
func renderWrappedLines(content string, prefix string, style interface{ Render(...string) string }) []string {
280+
parts := strings.Split(content, "\n")
281+
lines := make([]string, 0, len(parts))
282+
for _, part := range parts {
283+
lines = append(lines, style.Render(prefix+part))
284+
}
285+
return lines
286+
}
287+
288+
// renderToolContent 根据内容形态选择文件路径或普通弱文本样式。
289+
func renderToolContent(content string) string {
290+
if content == "" {
291+
return theme.MutedStyle().Render("-")
292+
}
293+
if strings.Contains(content, "/") || strings.Contains(content, ".") {
294+
return theme.FilePathStyle().Render(content)
295+
}
296+
return theme.MutedStyle().Render(content)
297+
}
298+
299+
// shouldSeparate 判断相邻条目之间是否需要空行分组。
300+
func shouldSeparate(previous *state.StreamEntry, current *state.StreamEntry) bool {
301+
if previous == nil || current == nil {
302+
return false
303+
}
304+
if previous.Type == "tool_start" && (current.Type == "tool_end" || current.Type == "tool_output") {
305+
return false
306+
}
307+
if previous.Type == "tool_output" && current.Type == "tool_end" {
308+
return false
309+
}
310+
return previous.Type != current.Type
311+
}
312+
313+
// shouldShowTimestamp 判断相邻条目之间是否需要时间戳分隔。
314+
func shouldShowTimestamp(previous *state.StreamEntry, current *state.StreamEntry) bool {
315+
if previous == nil || current == nil || previous.Timestamp.IsZero() || current.Timestamp.IsZero() {
316+
return false
317+
}
318+
return current.Timestamp.Sub(previous.Timestamp) > streamTimeGap
319+
}
320+
321+
// clampScroll 将滚动偏移限制在可见窗口允许范围内。
322+
func clampScroll(value int, max int) int {
323+
if value < 0 {
324+
return 0
325+
}
326+
if value > max {
327+
return max
328+
}
329+
return value
104330
}
105331

106332
// stringOrDash 在占位布局中用短横线表示空值。

0 commit comments

Comments
 (0)