okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: ctrl-click a path in the transcript to open it in an editorcc6ab84

main
14.6 KiB504 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"fmt"
7	"path/filepath"
8	"regexp"
9	"strings"
10	"time"
11
12	"charm.land/bubbles/v2/textarea"
13	tea "charm.land/bubbletea/v2"
14	"charm.land/lipgloss/v2"
15)
16
17type ActiveStreamKind string
18
19const (
20	StreamKindNone     ActiveStreamKind = ""
21	StreamKindText     ActiveStreamKind = "text"
22	StreamKindThinking ActiveStreamKind = "thinking"
23)
24
25type pendingOperation struct {
26	Handle  *daemon.OperationHandle
27	Expired bool
28}
29
30type PendingUserTurn struct {
31	Expired        bool
32	Handle         *daemon.OperationHandle
33	OperationID    string
34	BlockingReason string
35	Images         []daemon.ImageAttachment
36	Pastes         []string
37	Text           string
38	At             int64
39	Queued         bool
40}
41
42const (
43	MaxLiveStreamBytes   = 64 * 1024
44	MaxPendingUsers      = 10
45	MaxSettledLines      = 1000
46	MaxSettledLinesBytes = 256 * 1024
47	// readingSlack multiplies the caps while you are scrolled up, so output
48	// arriving as you read does not shrink the transcript above you.
49	readingSlack = 8
50	// olderPageRows is how many transcript rows a reset replays and each
51	// older page adds; the daemon widens both back to a turn's start.
52	olderPageRows = 120
53	fnvOffset64   = 14695981039346656037
54	fnvPrime64    = 1099511628211
55)
56
57func fnv1a(h uint64, s string) uint64 {
58	for i := 0; i < len(s); i++ {
59		h ^= uint64(s[i])
60		h *= fnvPrime64
61	}
62	return h
63}
64
65var (
66	sgrCode        = regexp.MustCompile(`\x1b\[[0-9;]*m`)
67	phaseResting   = daemon.PhaseResting
68	phaseReasoning = daemon.PhaseReasoning
69	phaseTool      = daemon.PhaseTool
70)
71
72// burstRowsKey is what the cached live burst rows were rendered for: which
73// entries end the transcript (count and evictions, since an eviction swaps the
74// head without changing the count), how wide they render, where paths are
75// named from, and which of them group at all.
76type burstRowsKey struct {
77	workspace string
78	entries   int
79	evicted   int
80	epoch     int
81	width     int
82	flags     DisplayFlags
83}
84
85// liveRowsKey is what the cached rows of the reply streaming in were
86// rendered for: the same transcript end as a burst, and the reply itself.
87type liveRowsKey struct {
88	burstRowsKey
89	text string
90	kind EntryKind
91}
92
93// liveFrame is the shortest wait between two renderings of a growing reply.
94const liveFrame = 33 * time.Millisecond
95
96type ChatModel struct {
97	streamCtx        context.Context
98	Usage            *daemon.Usage
99	progressByCallID map[string]*daemon.ToolProgress
100	progressOrder    []string
101	eventChan        chan streamDelivery
102	streamStopped    bool
103	// Images and Pastes are what was pasted into the composer as markers.
104	Images promptImages
105	Pastes promptPastes
106	// window is the context window of windowModel, read once per model so
107	// the footer can say how full the context is.
108	window  *int
109	client  *daemon.ChatClient
110	History *BoundedHistory
111
112	// Selection coordinates use frameLines, the whole transcript as last
113	// drawn, so a selection can extend beyond the viewport.
114	dragAnchor   *Point
115	streamCancel context.CancelFunc
116	windowModel  *string
117	// stretch is the phase run the status face animates.
118	stretch             stretch
119	SessionID           string
120	Status              daemon.AgentStatus
121	AgentName           string
122	Workspace           string
123	Host                string // the label of a remote workspace's host; "" when local
124	Model               string
125	Effort              string
126	Provider            string
127	CopyStatus          string
128	settledToolLabel    string
129	ThoughtProgressText string
130	Renderer            TranscriptRenderer
131
132	// hostHome is the remote home ~ names in the header, once the host's
133	// probe reported it for hostAsked, the workspace it was asked for.
134	hostHome, hostAsked string
135
136	// userRows are the settled rows where user messages start.
137	userRows      []int
138	effortOptions []string
139
140	// burstRows is the rendered frame of the trailing run of compact entries.
141	// Its key names every input the rows depend on, so a refresh that changes
142	// nothing redraws nothing, and nothing can ask for a stale frame.
143	burstRows []string
144	// liveRows is the reply streaming in, rendered whole. While only its
145	// text grows it is rendered again at most once per liveInterval, a few
146	// times what the last rendering took, so a long reply cannot spend the
147	// whole stream re-rendering itself; liveDue is a later redraw on its way.
148	// settled is each named entry's rows from the last rebuild while reading
149	// older history; see rebuildSettledLines.
150	settled      map[settledKey]settledBlock
151	liveRows     []string
152	liveRowsKey  liveRowsKey
153	liveDrawn    time.Time
154	liveInterval time.Duration
155	liveDue      bool
156	Notices      Notices
157	settledLines []string
158
159	pendingUsers         []PendingUserTurn
160	pendingContinuations map[string]pendingOperation
161	Glances              []PageGlance
162	// layoutSidebar is the sidebar width the last layout reserved.
163	layoutSidebar int
164	frameLines    []string
165	CommandMenu   CommandMenuModel
166	Viewport      pane
167
168	transcript         transcriptState
169	TextArea           textarea.Model
170	burstRowsKey       burstRowsKey
171	dragHead           Point
172	effortSelected     int
173	Generation         int64
174	terminalClipboard  terminalClipboardRead
175	copyStatusRevision uint64
176	burstEpoch         int
177	// burstRenders counts frame builds; tests watch it to catch a cache that
178	// redraws every refresh or misses an invalidation.
179	burstRenders  int
180	ProgressFrame int
181	Width         int
182	// moodSeed selects the status-face animation for the current stretch.
183	moodSeed            int64
184	Height              int
185	droppedSettledLines int
186	settledLinesBytes   int64
187	statusRevision      uint64
188	statusPoll          uint64
189	// statusPollResting is set while the next status poll is the slow one.
190	statusPollResting bool
191	scrollOffset      int
192
193	scrollLimit int
194	dragDir     int
195	dragCtrl    bool
196	dragGen     int
197	// olderBefore is the first transcript row carried by the reset or last older page.
198	olderBefore int64
199	// settledOffset counts notice rows above the settled transcript.
200	settledOffset   int
201	Flags           DisplayFlags
202	Follow          bool
203	TurnFailed      bool
204	Stopping        bool
205	Stopped         bool
206	animationActive bool
207	// rebuilding defers trimming until a re-render has placed the reading position.
208	rebuilding bool
209
210	// olderMore reports whether transcript rows precede olderBefore.
211	olderMore bool
212	// loadingOlder prevents overlapping history fetches.
213	loadingOlder      bool
214	isSending         bool
215	interruptDeferred bool
216	hostAuth          *hostSignIn
217	sentHere          bool
218	// graphemes says the terminal measures grapheme clusters (mode 2027),
219	// which Bubble Tea turns off when it hands the terminal to the editor.
220	graphemes bool
221}
222
223// NewChatModel requires a session client. A nil client panics.
224func NewChatModel(session *daemon.Session, client *daemon.ChatClient) ChatModel {
225	if client == nil {
226		panic("tui.NewChatModel: nil client")
227	}
228	ta := textarea.New()
229	ta.Placeholder = ""
230	ta.Prompt = promptMark
231	ta.CharLimit = 0
232	ta.ShowLineNumbers = false
233	ta.SetPromptFunc(promptMarkWidth, func(info textarea.PromptInfo) string {
234		if info.LineNumber == 0 {
235			return promptMark
236		}
237		return strings.Repeat(" ", promptMarkWidth)
238	})
239	ta.KeyMap.InsertNewline.SetKeys("enter", "ctrl+m", "alt+enter", "shift+enter")
240	ta.KeyMap.WordBackward.SetKeys("alt+left", "alt+b", "ctrl+left")
241	ta.KeyMap.WordForward.SetKeys("alt+right", "alt+f", "ctrl+right")
242	ta.SetHeight(6)
243	st := ta.Styles()
244	st.Focused.Prompt, st.Blurred.Prompt = DefaultStyles.Prompt, DefaultStyles.Prompt
245	st.Focused.CursorLine, st.Blurred.CursorLine = lipgloss.NewStyle(), lipgloss.NewStyle()
246	st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint
247	st.Cursor.Blink, st.Cursor.Color = false, nil
248	ta.SetStyles(st)
249	ta.Focus()
250
251	vp := pane{width: 80, height: 20}
252
253	bh := NewBoundedHistory(500, 2*1024*1024)
254
255	ctx, cancel := context.WithCancel(context.Background())
256	gen := time.Now().UnixNano()
257
258	m := ChatModel{
259		SessionID:    session.ID,
260		Generation:   gen,
261		AgentName:    "albedo",
262		Workspace:    session.Workspace,
263		Host:         sessionHost(*session),
264		Model:        session.Model,
265		Effort:       session.Effort,
266		Provider:     session.Provider,
267		Glances:      session.Glances,
268		client:       client,
269		History:      bh,
270		Renderer:     TranscriptRenderer{Workspace: session.Workspace},
271		Viewport:     vp,
272		TextArea:     ta,
273		CommandMenu:  NewCommandMenuModel(),
274		Flags:        DisplayFlags{},
275		Follow:       true,
276		transcript:   newTranscriptState(),
277		streamCtx:    ctx,
278		streamCancel: cancel,
279		eventChan:    make(chan streamDelivery, 16),
280	}
281
282	return m
283}
284
285func (m *ChatModel) Close() {
286	m.terminalClipboard = terminalClipboardRead{}
287	if m.streamCancel != nil {
288		m.streamCancel()
289	}
290}
291
292func (m *ChatModel) AddNotice(message string) {
293	if m.Notices.AddNotice(message) {
294		m.syncViewportHeight()
295	}
296}
297
298func (m *ChatModel) AddError(message string) {
299	if m.Notices.AddError(message) {
300		m.syncViewportHeight()
301	}
302}
303
304func (m *ChatModel) ClearNotices() {
305	// A successful mutation does not repair a stopped subscription.
306	if m.streamStopped {
307		return
308	}
309	if len(m.Notices) > 0 {
310		m.Notices.Clear()
311		m.syncViewportHeight()
312	}
313}
314
315// Update handles msg, then fetches older history when it left you at the top.
316func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) {
317	if loaded, ok := msg.(ChatOlderLoadedMsg); ok {
318		return m.showOlder(loaded), nil
319	}
320	m, cmd := m.update(msg)
321	if older := m.loadOlder(); older != nil {
322		return m, tea.Batch(cmd, older)
323	}
324	return m, cmd
325}
326
327func (m ChatModel) update(msg tea.Msg) (ChatModel, tea.Cmd) {
328	if cmd, handled := m.handleOperationResults(msg); handled {
329		return m, cmd
330	}
331	if cmd, handled := m.handleInput(msg); handled {
332		return m, cmd
333	}
334	var cmds []tea.Cmd
335
336	switch msg := msg.(type) {
337
338	case ChatStatusPollMsg:
339		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.Poll != m.statusPoll || m.streamStopped {
340			return m, nil
341		}
342		return m, m.statusCmd()
343
344	case ChatStatusMsg:
345		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || m.streamStopped {
346			return m, nil
347		}
348		if msg.Err == nil && msg.Status != nil && msg.Revision == m.statusRevision {
349			previousChrome := m.chromeRows()
350			m.Status = *msg.Status
351			m.Glances = msg.Glances
352			if m.sidebarWidth() != m.layoutSidebar {
353				m.SetSize(m.Width, m.Height)
354			} else if m.chromeRows() != previousChrome {
355				m.syncViewportHeight()
356			}
357			defer m.reseedMood()
358			// most polls find the session idle with nothing live to settle
359			live := m.latestProgress() != nil || m.settledToolLabel != "" || m.ThoughtProgressText != "" || m.transcript.activeKind != StreamKindNone || m.transcript.turn != nil && m.transcript.turn.begun()
360			if live && (!m.Status.Running || m.Status.Idle) {
361				m.settleActiveStream()
362				m.clearAction()
363				if m.transcript.turn != nil && m.transcript.turn.begun() {
364					for _, entry := range m.transcript.closeTurn(false) {
365						m.appendSettledEntry(entry)
366					}
367				}
368				m.refreshViewportContent()
369			}
370			m.reconcileStopped()
371		}
372		home := m.hostHomeCmd()
373		return m, tea.Batch(m.startAnimation(), m.statusPollCmd(), home)
374
375	case ChatHostMsg:
376		if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Workspace == m.Workspace && msg.Err == nil {
377			_, m.hostHome = daemon.SplitLocation(msg.Status.Home)
378		}
379		return m, nil
380
381	case ChatStreamEventMsg:
382		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || m.streamStopped {
383			return m, nil
384		}
385		var wake tea.Cmd
386		// only a live event outdates a status reply in flight
387		if !msg.Event.Replayed {
388			m.statusRevision++
389			// a resting poll would learn of the new activity seconds late
390			if m.statusPollResting {
391				m.statusPollResting = false
392				wake = m.statusCmd()
393			}
394		}
395		if msg.Event.Type == daemon.EventInvalidate {
396			return m, tea.Batch(m.waitForNextEvent(), wake)
397		}
398		before, entries := m.transcript, m.History.Len()
399		m.handleStreamEvent(msg.Event)
400		var draw tea.Cmd
401		if m.onlyGrew(before, entries) && time.Since(m.liveDrawn) < m.liveInterval {
402			draw = m.drawLiveLater()
403		} else {
404			m.refreshViewportContent()
405		}
406
407		var fade, window tea.Cmd
408		if msg.Event.Type == daemon.EventUsage {
409			fade, window = m.cacheFadeCmd(), m.windowCmd()
410		}
411		var reconcile []tea.Cmd
412		if msg.Event.Type == daemon.EventReset {
413			for _, pending := range m.pendingUsers {
414				if pending.Handle != nil && !pending.Expired {
415					reconcile = append(reconcile, m.queryOperationCmd(pending.Handle))
416				}
417			}
418			for _, pending := range m.pendingContinuations {
419				if pending.Handle != nil && !pending.Expired {
420					reconcile = append(reconcile, m.queryOperationCmd(pending.Handle))
421				}
422			}
423		}
424		reconcile = append(reconcile, m.waitForNextEvent(), m.startAnimation(), window, fade, wake, draw)
425		return m, tea.Batch(reconcile...)
426
427	case ChatLiveDrawMsg:
428		if msg.SessionID == m.SessionID && msg.Generation == m.Generation {
429			m.liveDue = false
430			m.refreshViewportContent()
431		}
432		return m, nil
433
434	case ChatWindowMsg:
435		if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Err == nil {
436			m.window, m.windowModel = msg.Tokens, &msg.Model
437		}
438		return m, nil
439
440	case ChatCacheFadeMsg:
441		// newer usage has its own countdown
442		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.Usage != m.Usage {
443			return m, nil
444		}
445		return m, m.cacheFadeCmd()
446
447	case FileOpenResultMsg:
448		if msg.SessionID == m.SessionID && msg.Err != nil {
449			m.AddError(fmt.Sprintf("could not open %s: %v", filepath.Base(msg.Target.Path), msg.Err))
450		}
451		return m, nil
452
453	case ChatClearCopyStatusMsg:
454		if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Revision == m.copyStatusRevision {
455			m.CopyStatus = ""
456		}
457		return m, nil
458
459	case ChatProgressTickMsg:
460		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || m.streamStopped {
461			return m, nil
462		}
463		if !m.animating() {
464			m.animationActive = false
465			return m, nil
466		}
467		// the face lives in the status line, so the transcript stays as it is
468		m.ProgressFrame++
469		return m, m.progressTickCmd()
470
471	case ChatStreamResultMsg:
472		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || m.streamStopped {
473			return m, nil
474		}
475		m.clearAction()
476		if msg.Recovering {
477			m.AddNotice(fmt.Sprintf("Stream data could not be read; refreshing history: %v", msg.Err))
478			return m, m.waitForNextEvent()
479		}
480		m.streamStopped = true
481		m.animationActive = false
482		m.AddError(fmt.Sprintf("Session stream stopped: %v. Reopen the session to reconnect.", msg.Err))
483		m.refreshViewportContent()
484		return m, nil
485
486	case ChatStreamClosedMsg:
487		if msg.SessionID == m.SessionID && msg.Generation == m.Generation {
488			m.clearAction()
489			m.refreshViewportContent()
490		}
491		return m, nil
492
493	case chatHostAuthMsg:
494		m.offerSignIn(msg)
495		return m, nil
496
497	case chatSignedInMsg:
498		m.signedIn(msg)
499		return m, nil
500
501	}
502
503	return m, m.updateComposer(msg, cmds)
504}