@@ -3,12 +3,21 @@ package components
33import (
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 行为流,包括消息、工具调用和状态条目。
1322type 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 的滚动按键,不维护冗余业务状态 。
3039func (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,按滚动窗口选择可见条目并进行宽度安全截断 。
3563func (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 可用宽度。
5491func (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