package tui import ( "albedo/cli/internal/daemon" "cmp" "context" "errors" "fmt" "math" "math/rand/v2" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) type ChatBackToSessionsMsg struct{} type ChatQuitMsg struct{} // ChatEditorFinishedMsg is sent after the external editor process exits. type ChatEditorFinishedMsg struct { Err error SessionID string Text string Generation int64 Edited bool } // ChatOlderLoadedMsg carries a page of history from before what is shown. type ChatOlderLoadedMsg struct { Err error Page *daemon.HistoryPage SessionID string Generation int64 } type ChatNewSessionMsg struct{} type ChatOpenModelPickerMsg struct{} type ChatOpenExtensionPickerMsg struct{} type ChatOpenTreePickerMsg struct{} type ChatOpenContextInspectorMsg struct{} type ChatOpenPageMsg struct { Command string } type ChatOpenLoginMsg struct { Name string } type ChatExecuteCommandMsg struct { Name string Args string } type ChatStreamEventMsg struct { SessionID string Event daemon.StreamEvent Generation int64 } type ChatProgressTickMsg struct { SessionID string Generation int64 } type ChatClearCopyStatusMsg struct { SessionID string Generation int64 Revision uint64 } type ChatStatusMsg struct { Err error Status *daemon.AgentStatus SessionID string Generation int64 Revision uint64 } // ChatWindowMsg carries the context window for the model a usage event named. type ChatWindowMsg struct { Err error Tokens *int SessionID string Model string Generation int64 } // ChatCacheFadeMsg arrives when Usage's cached count reaches its next step. type ChatCacheFadeMsg struct { Usage *daemon.Usage SessionID string Generation int64 } type ChatStatusPollMsg struct { SessionID string Generation int64 } type ChatStreamClosedMsg struct { SessionID string Generation int64 } type ChatTurnSentMsg struct { Err error Image *daemon.ImageAttachment SessionID string Prompt string Generation int64 Continue bool OK bool Queued bool } type ChatInterruptMsg struct { Err error SessionID string Generation int64 Interrupted bool } // ChatOpenFolderPickerMsg asks for the folder picker; Retry is the turn a // missing workspace refused. type ChatOpenFolderPickerMsg struct{ Retry *WorkspaceRetry } type ActiveStreamKind string const ( StreamKindNone ActiveStreamKind = "" StreamKindText ActiveStreamKind = "text" StreamKindThinking ActiveStreamKind = "thinking" ) type PendingUserTurn struct { Image *daemon.ImageAttachment Text string At int64 Queued bool } const ( MaxLiveStreamBytes = 64 * 1024 MaxPendingUsers = 10 MaxSettledLines = 1000 MaxSettledLinesBytes = 256 * 1024 // readingSlack multiplies the caps while you are scrolled up, so output // arriving as you read does not shrink the transcript above you. readingSlack = 8 // olderPageRows is how many transcript rows a reset replays and each // older page adds; the daemon widens both back to a turn's start. olderPageRows = 120 fnvOffset64 = 14695981039346656037 fnvPrime64 = 1099511628211 ) func fnv1a(h uint64, s string) uint64 { for i := 0; i < len(s); i++ { h ^= uint64(s[i]) h *= fnvPrime64 } return h } var ( sgrCode = regexp.MustCompile(`\x1b\[[0-9;]*m`) phaseResting = daemon.PhaseResting phaseReasoning = daemon.PhaseReasoning phaseTool = daemon.PhaseTool ) func wrapOrChunkLine(line string, width int) []string { if width <= 0 { width = 80 } if ansi.StringWidth(line) <= width { return []string{line} } // Match wrap-ansi's hard:true, trim:false word placement: a separator // consumes a cell before deciding whether the next word fits. words := strings.Split(line, " ") rows := []string{""} used := 0 for i, word := range words { if i > 0 { if used >= width { rows = append(rows, "") used = 0 } rows[len(rows)-1] += " " used++ } wordWidth := ansi.StringWidth(word) if wordWidth > width { remaining := width - used breaksHere := 1 + (wordWidth-remaining-1)/width breaksNext := (wordWidth - 1) / width if breaksNext < breaksHere { rows = append(rows, "") used = 0 } wrapped := strings.Split(ansi.Hardwrap(rows[len(rows)-1]+word, width, true), "\n") rows[len(rows)-1] = wrapped[0] rows = append(rows, wrapped[1:]...) used = ansi.StringWidth(rows[len(rows)-1]) continue } if used+wordWidth > width && used > 0 && wordWidth > 0 { rows = append(rows, "") used = 0 } rows[len(rows)-1] += word used += wordWidth } // Every viewport row can be rendered alone after a history scroll. Reopen // styles that cross a physical wrap, as wrap-ansi does for the TS rows. style := "" for i, row := range rows { if style != "" { rows[i] = style + row } for _, code := range sgrCode.FindAllString(row, -1) { if code == "\x1b[0m" || code == "\x1b[m" { style = "" } else { style += code } } if style != "" && i < len(rows)-1 { rows[i] += "\x1b[0m" } } return rows } // burstRowsKey is what the cached live burst rows were rendered for: which // entries end the transcript (count and evictions, since an eviction swaps the // head without changing the count), how wide they render, where paths are // named from, and which of them group at all. type burstRowsKey struct { workspace string entries int evicted int epoch int width int flags DisplayFlags } type ChatModel struct { Styles Styles streamCtx context.Context Usage *daemon.Usage // inFlight is the last progress of the call the action row follows. It // outlives Progress, which the result clears, so the row can still say // what the call did once it ends. inFlight *daemon.ToolProgress eventChan chan daemon.StreamEvent AttachedImage *daemon.ImageAttachment // window is the context window of windowModel, read once per model so // the footer can say how full the context is. window *int client *daemon.ChatClient History *BoundedHistory // Selection coordinates use frameLines, the whole transcript as last // drawn, so a selection can extend beyond the viewport. dragAnchor *Point streamCancel context.CancelFunc windowModel *string Progress *daemon.ToolProgress // stretch is the phase run the status face animates. stretch stretch SessionID string Status daemon.AgentStatus AgentName string Workspace string Model string Effort string Provider string CopyStatus string ToolProgressText string ThoughtProgressText string Renderer TranscriptRenderer // userRows are the settled rows where user messages start. userRows []int effortOptions []string // burstRows is the rendered frame of the trailing run of compact entries. // Its key names every input the rows depend on, so a refresh that changes // nothing redraws nothing, and nothing can ask for a stale frame. burstRows []string Notices Notices settledLines []string pendingUsers []PendingUserTurn Glances []PageGlance frameLines []string CommandMenu CommandMenuModel Viewport viewport.Model transcript transcriptState TextArea textarea.Model burstRowsKey burstRowsKey dragHead Point effortSelected int Generation int64 copyStatusRevision uint64 burstEpoch int // burstRenders counts frame builds; tests watch it to catch a cache that // redraws every refresh or misses an invalidation. burstRenders int ProgressFrame int Width int // moodSeed selects the status-face animation for the current stretch. moodSeed int64 Height int droppedSettledLines int settledLinesBytes int64 statusRevision uint64 scrollOffset int scrollLimit int dragDir int dragGen int // olderBefore is the first transcript row carried by the reset or last older page. olderBefore int64 // settledOffset counts notice rows above the settled transcript. settledOffset int Flags DisplayFlags Follow bool TurnFailed bool Stopping bool Stopped bool animationActive bool // rebuilding defers trimming until a re-render has placed the reading position. rebuilding bool // olderMore reports whether transcript rows precede olderBefore. olderMore bool // loadingOlder prevents overlapping history fetches. loadingOlder bool isSending bool interruptDeferred bool sentHere bool // graphemes says the terminal measures grapheme clusters (mode 2027), // which Bubble Tea turns off when it hands the terminal to the editor. graphemes bool } // NewChatModel requires a session client. A nil client panics. func NewChatModel(session *daemon.Session, client *daemon.ChatClient) ChatModel { if client == nil { panic("tui.NewChatModel: nil client") } ta := textarea.New() ta.Placeholder = "" ta.Prompt = promptMark ta.CharLimit = 0 ta.ShowLineNumbers = false ta.SetPromptFunc(promptMarkWidth, func(info textarea.PromptInfo) string { if info.LineNumber == 0 { return promptMark } return strings.Repeat(" ", promptMarkWidth) }) ta.KeyMap.InsertNewline.SetKeys("enter", "ctrl+m", "alt+enter", "shift+enter") ta.KeyMap.WordBackward.SetKeys("alt+left", "alt+b", "ctrl+left") ta.KeyMap.WordForward.SetKeys("alt+right", "alt+f", "ctrl+right") ta.SetHeight(6) st := ta.Styles() st.Focused.Prompt, st.Blurred.Prompt = DefaultStyles.Prompt, DefaultStyles.Prompt st.Focused.CursorLine, st.Blurred.CursorLine = lipgloss.NewStyle(), lipgloss.NewStyle() st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint st.Cursor.Blink, st.Cursor.Color = false, nil ta.SetStyles(st) ta.Focus() vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) vp.YPosition = 0 bh := NewBoundedHistory(500, 2*1024*1024) ctx, cancel := context.WithCancel(context.Background()) gen := time.Now().UnixNano() m := ChatModel{ SessionID: session.ID, Generation: gen, AgentName: "albedo", Workspace: session.Workspace, Model: session.Model, Effort: session.Effort, Provider: session.Provider, client: client, History: bh, Renderer: TranscriptRenderer{Styles: DefaultStyles, Workspace: session.Workspace}, Viewport: vp, TextArea: ta, CommandMenu: NewCommandMenuModel(), Flags: DisplayFlags{}, Follow: true, Styles: DefaultStyles, transcript: newTranscriptState(), streamCtx: ctx, streamCancel: cancel, eventChan: make(chan daemon.StreamEvent, 16), } return m } func (m *ChatModel) Close() { if m.streamCancel != nil { m.streamCancel() } } func (m *ChatModel) syncViewportHeight() { if m == nil || m.History == nil || m.Height <= 0 { return } m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows())) m.refreshViewportContent() } func (m *ChatModel) AddNotice(message string) { if m.Notices.AddNotice(message) { m.syncViewportHeight() } } func (m *ChatModel) AddError(message string) { if m.Notices.AddError(message) { m.syncViewportHeight() } } func (m *ChatModel) ClearNotices() { if len(m.Notices) > 0 { m.Notices.Clear() m.syncViewportHeight() } } // wordBackwardAtStart reports whether the prompt has no word before its cursor. // The textarea's word-backward handler loops indefinitely in this case. func (m ChatModel) wordBackwardAtStart() bool { lines := strings.Split(m.TextArea.Value(), "\n") row := m.TextArea.Line() if row < 0 || row >= len(lines) { return false } if slices.ContainsFunc(lines[:row], func(line string) bool { return strings.TrimSpace(line) != "" }) { return false } col := m.TextArea.LineInfo().StartColumn + m.TextArea.LineInfo().ColumnOffset current := []rune(lines[row]) col = max(0, min(col, len(current))) return strings.TrimSpace(string(current[:col])) == "" } func (m ChatModel) promptLines() int { lineCount := m.TextArea.LineCount() if lineCount <= 1 { return max(1, m.TextArea.LineInfo().Height) } cp := m.TextArea for cp.Line() > 0 { cp.CursorUp() } total := 0 for line := range lineCount { total += cp.LineInfo().Height if line < lineCount-1 { curr := cp.Line() for cp.Line() == curr { beforeRow := cp.LineInfo().RowOffset cp.CursorDown() if cp.Line() == curr && cp.LineInfo().RowOffset == beforeRow { break } } } } return max(1, total) } func (m ChatModel) maxPromptHeight() int { if m.Height > 0 { return max(1, min(6, (m.Height-8)/2)) } return 6 } func (m ChatModel) promptHeight() int { return min(m.promptLines(), m.maxPromptHeight()) } func (m ChatModel) inputRows() int { if len(m.effortOptions) > 0 { return 4 // breathing room, title, choices, keyboard hint; no composer } menu := min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) return m.promptHeight() + menu } func (m ChatModel) chromeRows() int { return m.inputRows() + m.Notices.ChromeRows() } func (m *ChatModel) syncLayout() { m.TextArea.SetHeight(m.maxPromptHeight()) m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows())) m.refreshViewportContent() } func (m *ChatModel) SetSize(width, height int) { if m == nil || m.History == nil { return } m.Width, m.Height = max(1, width), max(1, height) available := max(1, m.Width-2*m.padding()) transcriptWidth := available if sw := m.sidebarWidth(); sw > 0 { transcriptWidth -= sw + 2 } m.Viewport.SetWidth(transcriptWidth) m.TextArea.SetWidth(available) m.TextArea.SetHeight(m.maxPromptHeight()) m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows())) // settled rows are rendered at the body width alone, so only a new body // width renders them again if body := min(100, available); body != m.Renderer.BodyWidth { m.Renderer.BodyWidth = body m.rebuildSettledLines() } m.refreshViewportContent() } func (m *ChatModel) rebuildSettledLines() { // Re-rendering retained history must not move the reading position: it // keeps its distance from the end of the settled rows, and trimming waits // until it is placed. fromEnd := m.settledOffset + len(m.settledLines) - m.scrollOffset m.settledLines = nil m.settledLinesBytes = 0 m.droppedSettledLines = 0 m.userRows = nil m.rebuilding = true entries := m.History.Entries() for i := range entries { m.appendBlock(entries[:i], entries[i]) } m.rebuilding = false m.settledOffset = len(m.headerLines()) m.scrollOffset = max(0, m.settledOffset+len(m.settledLines)-fromEnd) m.trimSettledLines() } // headerLines lead the transcript: where older history stands. Their count // only changes with what is loaded, and refreshViewportContent keeps the // reading position steady when it does. func (m ChatModel) headerLines() []string { var line string switch { case m.loadingOlder: line = m.Styles.Faint.Render("↑ loading earlier messages…") case m.hasOlder(): line = m.Styles.Faint.Render("↑ earlier messages load as you scroll up") case m.History.TruncationNotice() != "": line = m.Styles.Warning.Render(m.History.TruncationNotice()) default: return nil } return []string{markChrome + m.Renderer.rail(laneNone) + line, ""} } // hasOlder is whether scrolling past the top can show more: rows trimmed // from the rendering, or transcript the daemon has not sent. func (m ChatModel) hasOlder() bool { _, more := m.olderCursor() return m.droppedSettledLines > 0 || more } // olderCursor is the row the next older page ends before. Evicted entries // move it forward: everything up to the newest evicted row is re-read. func (m ChatModel) olderCursor() (int64, bool) { before, more := m.olderBefore, m.olderMore if through := m.History.EvictedThrough(); through > 0 && through >= before { before, more = through+1, true } return before, more && before > 0 } // appendBlock settles entry after the entries before it. func (m *ChatModel) appendBlock(before []HistoryEntry, entry HistoryEntry) { rows, head := m.Renderer.Settle(before, entry, m.Flags) if entry.Kind == EntryUser { m.userRows = append(m.userRows, len(m.settledLines)+head) } for _, row := range rows { m.settledLines = append(m.settledLines, row) m.settledLinesBytes += int64(len(row)) } m.trimSettledLines() } // trimSettledLines drops the oldest rows past the caps. While you are scrolled // up the caps stretch by readingSlack and the row you are reading is never // dropped, so output arriving meanwhile cannot push your place off the top; // the normal caps apply again once the transcript follows its end. func (m *ChatModel) trimSettledLines() { if m.rebuilding { return } maxLines, maxBytes := MaxSettledLines, int64(MaxSettledLinesBytes) if !m.Follow { maxLines, maxBytes = maxLines*readingSlack, maxBytes*readingSlack } dropped := 0 for len(m.settledLines) > maxLines || m.settledLinesBytes > maxBytes { if len(m.settledLines) == 0 || !m.Follow && dropped >= m.scrollOffset-m.settledOffset { break } removed := m.settledLines[0] m.settledLinesBytes -= int64(len(removed)) m.settledLines[0] = "" m.settledLines = m.settledLines[1:] dropped++ } if dropped > 0 { m.droppedSettledLines += dropped m.scrollOffset = max(0, m.scrollOffset-dropped) kept := m.userRows[:0] for _, row := range m.userRows { if row >= dropped { kept = append(kept, row-dropped) } } m.userRows = kept fresh := make([]string, len(m.settledLines)) for i := range m.settledLines { fresh[i] = strings.Clone(m.settledLines[i]) m.settledLines[i] = "" } m.settledLines = fresh } } // replaceSettled swaps the n settled rows at from for rows, and returns how // many rows the caps then dropped from the top. func (m *ChatModel) replaceSettled(from, n int, rows []string) int { m.settledLinesBytes += rowBytes(rows) - rowBytes(m.settledLines[from:from+n]) m.settledLines = slices.Replace(m.settledLines, from, from+n, rows...) for i, at := range m.userRows { if at >= from+n { m.userRows[i] += len(rows) - n } } dropped := m.droppedSettledLines m.trimSettledLines() return m.droppedSettledLines - dropped } func rowBytes(rows []string) int64 { var n int64 for _, row := range rows { n += int64(len(row)) } return n } // clearAction drops the live action row; the next action starts a new one. func (m *ChatModel) clearAction() { m.ToolProgressText = "" m.inFlight = nil m.ThoughtProgressText = "" } func (m *ChatModel) appendSettledEntry(entry HistoryEntry) { if Compact(entry, m.Flags) { facts := factsOf(entry) entry.facts = &facts } m.appendBlock(m.History.Entries(), entry) m.History.Append(entry) } // settleActiveStream appends assembled text and retains a live thought's last // line as the compact action until the next action replaces it. func (m *ChatModel) settleActiveStream() { watched := !m.transcript.thinkingSince.IsZero() for _, entry := range m.transcript.settle(m.AgentName) { if watched && entry.Kind == EntryThinking { m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking") } m.appendSettledEntry(entry) } } func (m *ChatModel) refreshViewportContent() int { if m.Viewport.Height() != max(1, m.Height-6-m.chromeRows()) { m.SetSize(m.Width, m.Height) return m.scrollLimit } allLines := m.headerLines() if !m.Follow { // a header growing or shrinking above must not move what you read m.scrollOffset = max(0, m.scrollOffset+len(allLines)-m.settledOffset) } m.settledOffset = len(allLines) allLines = append(allLines, m.settledLines...) last, stacks := laneNone, false if entries := m.History.Entries(); len(entries) > 0 { last = laneOf(entries[len(entries)-1]) stacks = Compact(entries[len(entries)-1], m.Flags) if burst := trailingBurst(entries, m.Flags); len(burst) > 0 { key := burstRowsKey{entries: len(entries), evicted: m.History.EvictedCount(), epoch: m.burstEpoch, width: m.Renderer.BodyWidth, workspace: m.Renderer.Workspace, flags: m.Flags} if key != m.burstRowsKey { m.burstRows = m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags) m.burstRowsKey = key m.burstRenders++ } allLines = append(allLines, m.burstRows...) } } if m.transcript.activeKind != StreamKindNone && m.transcript.activeText != "" && (m.transcript.activeKind != StreamKindThinking || m.Flags.Thinking) { activeEntry := HistoryEntry{Kind: m.transcript.activeEntryKind(), Speaker: m.AgentName, Text: m.transcript.activeText, Live: true} rows, _ := m.Renderer.Block(m.History.Entries(), activeEntry, m.Flags) allLines = append(allLines, rows...) last, stacks = laneOf(activeEntry), false } // One live-tail slot stays occupied by the last action until another begins. action := "" switch { case m.transcript.activeKind == StreamKindThinking && m.transcript.activeText != "" && !m.Flags.Thinking: action = m.renderThought() case m.ToolProgressText != "" && (m.Progress != nil || !m.Flags.Tools): action = m.renderProgress() case m.ThoughtProgressText != "" && !m.Flags.Thinking: width := max(1, m.Renderer.BodyWidth-railWidth) action = markChrome + m.Styles.Faint.Render(ansi.Truncate(m.ThoughtProgressText, width, "…")) } if action != "" { if len(allLines) > 0 && !stacks { allLines = append(allLines, strings.TrimRight(m.Renderer.rail(joint(last, laneBusy)), " ")) } allLines = append(allLines, m.Renderer.rail(laneBusy)+action) } allLines = append(allLines, m.pendingRows()...) m.frameLines = allLines totalLines := len(allLines) vpHeight := max(1, m.Viewport.Height()) maxScroll := max(0, totalLines-vpHeight) m.scrollLimit = maxScroll var visibleSlice []string if m.Follow { m.scrollOffset = maxScroll visibleSlice = allLines[maxScroll:totalLines] } else { m.scrollOffset = max(0, min(m.scrollOffset, maxScroll)) end := min(totalLines, m.scrollOffset+vpHeight) if m.scrollOffset < end { visibleSlice = allLines[m.scrollOffset:end] } } m.Viewport.SetContent(strings.Join(visibleSlice, "\n")) if m.Follow { m.Viewport.GotoBottom() } else { m.Viewport.GotoTop() } return maxScroll } // pendingRows are your messages the daemon has not echoed yet, greyed out at // the end of the transcript where they will settle. Each follows whatever is // live above it, so the rails and names join up as they will once settled. func (m ChatModel) pendingRows() []string { if len(m.pendingUsers) == 0 { return nil } before := slices.Clone(m.History.Entries()) if m.transcript.activeKind != StreamKindNone && m.transcript.activeText != "" { before = append(before, HistoryEntry{Kind: m.transcript.activeEntryKind(), Speaker: m.AgentName}) } if m.ToolProgressText != "" && (m.Progress != nil || !m.Flags.Tools) { before = append(before, HistoryEntry{Kind: EntryTool, Speaker: m.AgentName}) } else if m.ThoughtProgressText != "" && !m.Flags.Thinking { before = append(before, HistoryEntry{Kind: EntryThinking, Speaker: m.AgentName}) } var rows []string for _, p := range m.pendingUsers { state := sending if p.Queued { state = queued } entry := HistoryEntry{Kind: EntryUser, Speaker: "You", Text: p.Text, Timestamp: p.At, Pending: state} block, _ := m.Renderer.Block(before, entry, m.Flags) rows = append(rows, block...) before = append(before, entry) } return rows } // jumpToYou scrolls to the start of your previous or next message. Past // your newest message it follows the live transcript again. func (m *ChatModel) jumpToYou(back bool) { at := m.scrollOffset if m.Follow { at = m.scrollLimit } target := -1 for _, row := range m.userRows { row += m.settledOffset if back && row < at { target = row } if !back && row > at { target = row break } } switch { case target >= 0 && target < m.scrollLimit: m.Follow = false m.scrollOffset = target case !back || target >= 0: m.Follow = true } m.refreshViewportContent() } // scrollBy moves through the complete rendered transcript, including live output. func (m *ChatModel) scrollBy(rows int) { if rows < 0 { m.Follow = false } else if m.Follow { return } m.scrollOffset = max(0, m.scrollOffset+rows) maxScroll := m.refreshViewportContent() if rows > 0 && m.scrollOffset >= maxScroll { m.Follow = true } } func (m ChatModel) animating() bool { return m.isSending || len(m.pendingUsers) > 0 || m.Stopping || m.Progress != nil || m.Status.Running && !m.Status.Idle } func (m *ChatModel) startAnimation() tea.Cmd { if m.animationActive || !m.animating() { return nil } m.animationActive = true return m.progressTickCmd() } func (m ChatModel) progressTickCmd() tea.Cmd { id, generation := m.SessionID, m.Generation return tea.Tick(faceInterval, func(time.Time) tea.Msg { return ChatProgressTickMsg{SessionID: id, Generation: generation} }) } func (m ChatModel) waitForNextEvent() tea.Cmd { sessID, gen, ch, ctx := m.SessionID, m.Generation, m.eventChan, m.streamCtx return func() tea.Msg { select { case evt, ok := <-ch: if !ok { return ChatStreamClosedMsg{SessionID: sessID, Generation: gen} } return ChatStreamEventMsg{SessionID: sessID, Generation: gen, Event: evt} case <-ctx.Done(): return ChatStreamClosedMsg{SessionID: sessID, Generation: gen} } } } func (m ChatModel) startStreamSubscription() tea.Cmd { client, ctx, ch := m.client, m.streamCtx, m.eventChan go func() { defer close(ch) for ctx.Err() == nil { _ = client.Stream(ctx, olderPageRows, func(evt daemon.StreamEvent) error { select { case ch <- evt: return nil case <-ctx.Done(): return ctx.Err() } }) select { case <-ctx.Done(): return case <-time.After(500 * time.Millisecond): } } }() return m.waitForNextEvent() } func (m ChatModel) clearCopyStatusCmd() tea.Cmd { id, generation, revision := m.SessionID, m.Generation, m.copyStatusRevision return tea.Tick(3*time.Second, func(time.Time) tea.Msg { return ChatClearCopyStatusMsg{SessionID: id, Generation: generation, Revision: revision} }) } func (m ChatModel) statusCmd() tea.Cmd { client, ctx, id, generation, revision := m.client, m.streamCtx, m.SessionID, m.Generation, m.statusRevision return func() tea.Msg { value, err := client.GetStatus(ctx) return ChatStatusMsg{SessionID: id, Generation: generation, Revision: revision, Status: value, Err: err} } } // windowCmd reads the context window when usage names a model whose window // has not been read yet. func (m ChatModel) windowCmd() tea.Cmd { if m.Usage == nil || m.windowModel != nil && *m.windowModel == m.Usage.Model { return nil } client, ctx, id, generation, model := m.client, m.streamCtx, m.SessionID, m.Generation, m.Usage.Model return func() tea.Msg { tokens, err := client.ContextWindow(ctx) return ChatWindowMsg{SessionID: id, Generation: generation, Model: model, Tokens: tokens, Err: err} } } // cacheFadeCmd wakes the footer when the cached count reaches its next step. func (m ChatModel) cacheFadeCmd() tea.Cmd { if m.Usage == nil { return nil } now := time.Now().UnixMilli() for _, step := range m.Usage.CacheFade { if step.At > now { id, generation, usage := m.SessionID, m.Generation, m.Usage return tea.Tick(time.Duration(step.At-now)*time.Millisecond, func(time.Time) tea.Msg { return ChatCacheFadeMsg{SessionID: id, Generation: generation, Usage: usage} }) } } return nil } func (m ChatModel) statusPollCmd() tea.Cmd { id, generation := m.SessionID, m.Generation return tea.Tick(750*time.Millisecond, func(time.Time) tea.Msg { return ChatStatusPollMsg{SessionID: id, Generation: generation} }) } func (m ChatModel) Init() tea.Cmd { return tea.Batch(m.startStreamSubscription(), m.statusCmd()) } // Update handles msg, then fetches older history when it left you at the top. func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { if loaded, ok := msg.(ChatOlderLoadedMsg); ok { return m.showOlder(loaded), nil } m, cmd := m.update(msg) if older := m.loadOlder(); older != nil { return m, tea.Batch(cmd, older) } return m, cmd } // loadOlder runs when you are scrolled to the top of the transcript: rows // trimmed from the rendering come back from retained history first, then a // page is asked of the daemon. func (m *ChatModel) loadOlder() tea.Cmd { if m.Follow || m.scrollOffset > 0 || m.loadingOlder { return nil } if m.droppedSettledLines > 0 { m.rebuildSettledLines() m.refreshViewportContent() return nil } before, more := m.olderCursor() if !more { return nil } m.loadingOlder = true m.refreshViewportContent() client, id, generation := m.client, m.SessionID, m.Generation return func() tea.Msg { page, err := client.History(context.Background(), before, olderPageRows) return ChatOlderLoadedMsg{SessionID: id, Generation: generation, Page: page, Err: err} } } // showOlder puts a fetched page above what is shown without moving it. func (m *ChatModel) showOlder(msg ChatOlderLoadedMsg) ChatModel { if msg.SessionID != m.SessionID || msg.Generation != m.Generation || !m.loadingOlder { return *m } m.loadingOlder = false if msg.Err != nil { m.AddError(fmt.Sprintf("Could not load earlier messages: %v", msg.Err)) return *m } older := replayTranscript(msg.Page.Events, m.AgentName) for i := range older { if Compact(older[i], m.Flags) { facts := factsOf(older[i]) older[i].facts = &facts } } m.History.Prepend(older) m.olderBefore, m.olderMore = msg.Page.Before, msg.Page.More && msg.Page.Before > 0 m.rebuildSettledLines() m.refreshViewportContent() return *m } func (m ChatModel) update(msg tea.Msg) (ChatModel, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: // The selector owns keys while open, before chat navigation or composer input. if len(m.effortOptions) > 0 { switch msg.String() { case "left", "up": m.effortSelected = max(0, m.effortSelected-1) case "right", "down": m.effortSelected = min(len(m.effortOptions)-1, m.effortSelected+1) case "enter", "esc": level := m.effortOptions[m.effortSelected] m.effortOptions = nil m.syncLayout() if msg.String() == "enter" { return m, func() tea.Msg { return ChatExecuteCommandMsg{Name: "/effort", Args: level} } } case "ctrl+c": m.Close() return m, func() tea.Msg { return ChatQuitMsg{} } } return m, nil } if msg.String() == "left" && m.TextArea.Value() == "" { return m, func() tea.Msg { return ChatBackToSessionsMsg{} } } if msg.String() == "ctrl+c" || (msg.String() == "ctrl+d" && m.TextArea.Focused() && m.TextArea.Value() == "") { m.Close() return m, func() tea.Msg { return ChatQuitMsg{} } } if msg.String() == "ctrl+n" { return m, func() tea.Msg { return ChatNewSessionMsg{} } } if msg.String() == "ctrl+o" { return m, func() tea.Msg { return ChatOpenAgentsMsg{} } } if msg.String() == "esc" && m.dragAnchor != nil { m.dragAnchor, m.dragDir = nil, 0 return m, nil } inputVal := m.TextArea.Value() consumed := m.CommandMenu.OnKey( msg, inputVal, func(replacement string) { m.TextArea.SetValue(replacement) }, func(commandName string) { m.handleSubmittedCommand(commandName, &cmds) }, ) if consumed { m.syncLayout() return m, tea.Batch(cmds...) } switch msg.String() { case "esc": if m.AttachedImage != nil { m.AttachedImage = nil m.CopyStatus = "image removed" return m, nil } if m.isSending { m.interruptDeferred = true m.Stopping = true return m, nil } if m.Status.Running || len(m.pendingUsers) > 0 { m.Stopping = true cmds = append(cmds, m.interruptCmd()) return m, tea.Batch(cmds...) } case "ctrl+v": return m, PasteClipboardImageCmd(m.SessionID, m.Generation) case "ctrl+g": return m, m.openEditorCmd() case "pgup", "pgdown": delta := m.Viewport.Height() if msg.String() == "pgup" { delta = -delta } m.scrollBy(delta) return m, nil case "up": if m.TextArea.Line() == 0 && m.TextArea.LineInfo().RowOffset == 0 { m.scrollBy(-1) return m, nil } case "down": if m.TextArea.Line() >= m.TextArea.LineCount()-1 && m.TextArea.LineInfo().RowOffset >= m.TextArea.LineInfo().Height-1 { m.scrollBy(1) return m, nil } case "shift+up", "shift+down": m.jumpToYou(msg.String() == "shift+up") return m, nil case "ctrl+home", "ctrl+end": m.Follow = msg.String() == "ctrl+end" if !m.Follow { m.scrollOffset = 0 } m.refreshViewportContent() return m, nil case "ctrl+j", "ctrl+k": if msg.String() == "ctrl+j" { m.Flags.Diffs = !m.Flags.Diffs } else { m.Flags.Compaction = !m.Flags.Compaction } m.rebuildSettledLines() m.refreshViewportContent() return m, nil case "ctrl+left": // Bubbles wordLeft never terminates when everything before the cursor // is whitespace. Move to the input start directly in that case. if m.wordBackwardAtStart() { var cmd tea.Cmd m.TextArea, cmd = m.TextArea.Update(tea.KeyPressMsg{Code: tea.KeyHome, Mod: tea.ModCtrl}) return m, cmd } case "enter": trimmed := strings.TrimSpace(m.TextArea.Value()) // a draft typed while connecting waits in the composer if m.connecting() && (!strings.HasPrefix(trimmed, "/") || !m.isRecognizedCommand(trimmed)) { return m, nil } if trimmed != "" { m.TextArea.Reset() m.syncLayout() m.submitInput(trimmed, &cmds) return m, tea.Batch(cmds...) } return m, nil } case dragScrollMsg: cmd := m.dragScrolled(msg) return m, cmd case tea.MouseMsg: firstRow := 2 + m.Notices.ChromeRows() mouse := msg.Mouse() point := func() Point { screen := max(0, min(m.Viewport.Height()-1, mouse.Y-firstRow)) return Point{Row: m.scrollOffset + screen, Col: max(0, min(m.Viewport.Width(), mouse.X-m.padding()))} } switch msg := msg.(type) { case tea.MouseReleaseMsg: if m.dragAnchor == nil { return m, nil } m.dragHead = point() sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth} m.dragAnchor, m.dragDir = nil, 0 if sel.IsEmpty() { cmd := m.actAt(sel.Head.Row) return m, cmd } if text := SelectedText(m.frameLines, sel); text != "" { cmd := m.copied(text) return m, cmd } case tea.MouseMotionMsg: if m.dragAnchor != nil { m.dragHead = point() cmd := m.steerDragScroll(mouse.Y - firstRow) return m, cmd } case tea.MouseClickMsg: if msg.Button == tea.MouseLeft && mouse.Y >= firstRow && mouse.Y < firstRow+m.Viewport.Height() { pt := point() m.dragAnchor = &pt m.dragHead = pt } case tea.MouseWheelMsg: switch msg.Button { case tea.MouseWheelUp: m.scrollBy(-3) case tea.MouseWheelDown: m.scrollBy(3) } if m.dragAnchor != nil { m.dragHead = point() } } return m, nil case ClipboardImagePastedMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if msg.Err == nil && msg.Image != nil { m.AttachedImage = msg.Image m.CopyStatus = "" m.refreshViewportContent() } else if msg.Err != nil { m.AddError(fmt.Sprintf("Could not paste the image: %v", msg.Err)) } return m, nil case ChatStatusPollMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } return m, m.statusCmd() case ChatStatusMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if msg.Err == nil && msg.Status != nil && msg.Revision == m.statusRevision { m.Status = *msg.Status defer m.reseedMood() // most polls find the session idle with nothing live to settle live := m.Progress != nil || m.ToolProgressText != "" || m.ThoughtProgressText != "" || m.transcript.activeKind != StreamKindNone || m.transcript.turn != nil && m.transcript.turn.begun() if live && (!m.Status.Running || m.Status.Idle) { m.Progress = nil m.settleActiveStream() m.ToolProgressText = "" m.ThoughtProgressText = "" if m.transcript.turn != nil && m.transcript.turn.begun() { for _, entry := range m.transcript.closeTurn(false) { m.appendSettledEntry(entry) } } m.refreshViewportContent() } } return m, tea.Batch(m.startAnimation(), m.statusPollCmd()) case ChatStreamEventMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } // only a live event outdates a status reply in flight if !msg.Event.Replayed { m.statusRevision++ } m.handleStreamEvent(msg.Event) m.refreshViewportContent() var fade tea.Cmd if msg.Event.Type == daemon.EventUsage { fade = m.cacheFadeCmd() } return m, tea.Batch(m.waitForNextEvent(), m.startAnimation(), m.windowCmd(), fade) case ChatWindowMsg: if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Err == nil { m.window, m.windowModel = msg.Tokens, &msg.Model } return m, nil case ChatCacheFadeMsg: // newer usage has its own countdown if msg.SessionID != m.SessionID || msg.Generation != m.Generation || msg.Usage != m.Usage { return m, nil } return m, m.cacheFadeCmd() case ChatClearCopyStatusMsg: if msg.SessionID == m.SessionID && msg.Generation == m.Generation && msg.Revision == m.copyStatusRevision { m.CopyStatus = "" } return m, nil case ChatProgressTickMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if !m.animating() { m.animationActive = false return m, nil } // the face lives in the status line, so the transcript stays as it is m.ProgressFrame++ return m, m.progressTickCmd() case ChatStreamClosedMsg: return m, nil case ChatTurnSentMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } m.isSending = false if msg.Err != nil { m.interruptDeferred = false if !msg.Continue { if len(m.pendingUsers) > 0 { m.pendingUsers = m.pendingUsers[1:] } if m.TextArea.Value() == "" { m.TextArea.SetValue(msg.Prompt) } } if m.AttachedImage == nil && msg.Image != nil { m.AttachedImage = msg.Image } if wsErr, ok := errors.AsType[*daemon.WorkspaceMissingError](msg.Err); ok { m.Status.Running, m.Status.Idle = false, true retry := &WorkspaceRetry{Missing: wsErr.Workspace, Prompt: msg.Prompt, Continue: msg.Continue, Image: msg.Image} cmds = append(cmds, func() tea.Msg { return ChatOpenFolderPickerMsg{Retry: retry} }) } else { m.AddError(fmt.Sprintf("Could not send the message: %v", msg.Err)) errText := msg.Err.Error() if msg.Queued { errText = "Message was not queued: " + errText } m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: errText}) } m.refreshViewportContent() return m, tea.Batch(cmds...) } if msg.Queued { if i := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.Text == msg.Prompt && !p.Queued }); i >= 0 { m.pendingUsers[i].Queued = true } } // If user pressed Esc while this send was in flight, dispatch interrupt now that send is accepted if m.interruptDeferred { m.interruptDeferred = false m.Stopping = true cmds = append(cmds, m.interruptCmd()) } return m, tea.Batch(cmds...) case ChatEditorFinishedMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if msg.Edited { m.TextArea.SetValue(strings.TrimRight(msg.Text, "\r\n")) m.syncLayout() } if msg.Err != nil { m.AddError("Could not finish editing the prompt: " + msg.Err.Error()) } return m, nil case ChatInterruptMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if msg.Err != nil || !msg.Interrupted { m.Stopping = false if msg.Err != nil { m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: "Could not stop the current reply: " + msg.Err.Error()}) } } return m, nil } oldMenu, oldH := min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))), m.promptHeight() var taCmd tea.Cmd m.TextArea, taCmd = m.TextArea.Update(msg) if min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) != oldMenu || m.promptHeight() != oldH { m.syncLayout() } cmds = append(cmds, taCmd) return m, tea.Batch(cmds...) } // Moved takes the session's new folder, then sends again the turn a // missing folder refused. func (m *ChatModel) Moved(workspace string, retry *WorkspaceRetry) tea.Cmd { m.Workspace = workspace m.Renderer.Workspace = workspace m.rebuildSettledLines() // settled rows name paths from the old workspace m.refreshViewportContent() if retry == nil { return nil } if retry.Image != nil && m.AttachedImage == nil { m.AttachedImage = retry.Image } m.TextArea.Reset() m.syncLayout() var cmds []tea.Cmd prompt := retry.Prompt if retry.Continue { prompt = "." } m.submitInput(prompt, &cmds) return tea.Batch(cmds...) } func (m *ChatModel) sendCmd(prompt string, image *daemon.ImageAttachment, isCont bool) tea.Cmd { client, id, gen := m.client, m.SessionID, m.Generation return func() tea.Msg { msg := ChatTurnSentMsg{SessionID: id, Generation: gen, Prompt: prompt, Image: image, Continue: isCont} var ( res *daemon.SendResult err error ) if isCont { res, err = client.Continue(context.Background()) } else { res, err = client.Send(context.Background(), prompt, image) } msg.Err = err if err == nil && res != nil { msg.OK, msg.Queued = res.OK, res.Queued } return msg } } func (m *ChatModel) interruptCmd() tea.Cmd { client, id, gen := m.client, m.SessionID, m.Generation return func() tea.Msg { ok, err := client.Interrupt(context.Background()) return ChatInterruptMsg{SessionID: id, Generation: gen, Interrupted: ok, Err: err} } } func (m *ChatModel) isRecognizedCommand(input string) bool { trimmed := strings.TrimSpace(input) if !strings.HasPrefix(trimmed, "/") { return false } token := strings.Fields(trimmed)[0] switch token { case "/a", "/agents", "/sessions", "/q", "/quit", "/exit", "/new", "/model", "/extensions", "/plugins", "/tree", "/context", "/t", "/thinking", "/v", "/verbose", "/status", "/login", "/mouse", "/skills", "/instructions", "/mcp", "/cd": return true } return slices.ContainsFunc(m.CommandMenu.Catalog, func(cmd daemon.SessionCommand) bool { return cmd.Name == token }) } func (m *ChatModel) submitInput(input string, cmds *[]tea.Cmd) { if strings.HasPrefix(input, "/") && m.isRecognizedCommand(input) { m.handleSubmittedCommand(input, cmds) return } if len(m.pendingUsers) >= MaxPendingUsers { m.AddError("Too many messages are waiting. Wait for the current reply to finish before sending another message.") m.refreshViewportContent() return } m.ClearNotices() m.TurnFailed = false m.Stopped = false cmd := m.sendCmd(".", nil, true) if input != "." { img := m.AttachedImage m.AttachedImage = nil m.pendingUsers = append(m.pendingUsers, PendingUserTurn{ Text: input, Image: img, At: time.Now().UnixMilli(), }) cmd = m.sendCmd(input, img, false) } m.isSending, m.sentHere, m.Follow = true, true, true m.reseedMood() m.refreshViewportContent() *cmds = append(*cmds, cmd, m.startAnimation()) } func (m *ChatModel) handleSubmittedCommand(input string, cmds *[]tea.Cmd) { m.TextArea.Reset() m.syncLayout() trimmed := strings.TrimSpace(input) emit := func(msg tea.Msg) { *cmds = append(*cmds, func() tea.Msg { return msg }) } switch trimmed { case "/agents": emit(ChatOpenAgentsMsg{}) case "/a", "/sessions": emit(ChatBackToSessionsMsg{}) case "/q", "/quit", "/exit": m.Close() emit(ChatQuitMsg{}) case "/new": emit(ChatNewSessionMsg{}) case "/model": emit(ChatOpenModelPickerMsg{}) case "/extensions", "/plugins": emit(ChatOpenExtensionPickerMsg{}) case "/tree": emit(ChatOpenTreePickerMsg{}) case "/context": emit(ChatOpenContextInspectorMsg{}) case "/webhooks": emit(ChatOpenWebhooksPageMsg{}) case "/skills", "/instructions", "/mcp": emit(ChatOpenCapabilityPageMsg{Kind: trimmed[1:]}) case "/t", "/thinking", "/v", "/verbose": if trimmed == "/t" || trimmed == "/thinking" { m.Flags.Thinking = !m.Flags.Thinking } else { m.Flags.Tools = !m.Flags.Tools } m.rebuildSettledLines() m.refreshViewportContent() case "/status": statusText := fmt.Sprintf("session: %s\nworkspace: %s\nmodel: %s", m.SessionID, m.Workspace, m.Model) if m.Effort != "" { statusText += "\neffort: " + m.Effort } if m.Usage != nil { statusText += "\ntokens: " + formatUsage(m.Usage) } m.appendSettledEntry(HistoryEntry{Kind: EntryNote, Text: statusText}) m.refreshViewportContent() default: switch { case strings.HasPrefix(trimmed, "/model "): emit(ChatExecuteCommandMsg{Name: "/model", Args: strings.TrimSpace(trimmed[7:])}) case strings.HasPrefix(trimmed, "/login"): emit(ChatOpenLoginMsg{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "/login"))}) default: for _, cmd := range m.CommandMenu.Catalog { if cmd.Name == trimmed && cmd.Page != nil && *cmd.Page { emit(ChatOpenPageMsg{Command: trimmed}) return } } cmdName, cmdArgs, _ := strings.Cut(trimmed, " ") emit(ChatExecuteCommandMsg{Name: cmdName, Args: cmdArgs}) } } } func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { defer m.reseedMood() if evt.Replayed { // Replayed events do not describe current activity; preserve live status. defer func(live daemon.AgentStatus) { m.Status = live }(m.Status) } if evt.Type != daemon.EventToolProgress { m.Progress = nil } watchedThought := !m.transcript.thinkingSince.IsZero() entries := m.transcript.apply(evt, m.AgentName) for _, entry := range entries { m.appendSettledEntry(entry) } switch evt.Type { case daemon.EventReset: m.History.Clear() m.burstEpoch++ m.settledLines = nil m.settledLinesBytes, m.droppedSettledLines = 0, 0 m.clearAction() m.Usage = nil m.ClearNotices() m.TurnFailed, m.Stopping, m.Stopped = false, false, false m.Follow, m.scrollOffset = true, 0 m.pendingUsers = nil m.olderBefore, m.olderMore, m.loadingOlder = evt.Before, evt.More, false case daemon.EventCommitted: m.History.Stamp(evt.Seq) case daemon.EventRetry: m.clearAction() case daemon.EventUser: m.Stopped, m.TurnFailed = false, false m.clearAction() if !evt.Replayed { m.ClearNotices() } if evt.ClientID == m.client.ClientID() { if i := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.Text == evt.Text }); i >= 0 { m.pendingUsers = slices.Delete(m.pendingUsers, i, i+1) } } case daemon.EventText, daemon.EventThinking: if evt.Text != "" { m.ToolProgressText, m.ThoughtProgressText = "", "" m.Status.Running, m.Status.Idle, m.Status.Phase = true, false, &phaseReasoning // A thought flushed at the buffer cap keeps its last compact line // until the next delta, just like an explicitly settled thought. if evt.Type == daemon.EventThinking && m.transcript.activeText == "" && !m.transcript.thinkingSince.IsZero() { for _, entry := range entries { if entry.Kind == EntryThinking { m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking") } } } } case daemon.EventToolProgress: if evt.Replayed { break // A progress snapshot is not an action happening now. } m.Progress = evt.Progress if evt.Progress != nil { m.ThoughtProgressText = "" m.Status.Running, m.Status.Idle, m.Status.Phase = true, false, &phaseTool m.inFlight = evt.Progress m.ToolProgressText = actionLabel(evt.Progress, nil) } case daemon.EventTool: m.ThoughtProgressText = "" m.Status.Running, m.Status.Idle = true, false m.ToolProgressText = "" if !evt.Replayed { for i := len(entries) - 1; i >= 0; i-- { if entries[i].Kind == EntryTool { m.ToolProgressText = actionLabel(m.inFlight, &entries[i]) break } } } m.inFlight = nil case daemon.EventMessage, daemon.EventNote, daemon.EventCompacted: m.clearAction() case daemon.EventError: m.TurnFailed = true m.clearAction() m.Status.Running, m.Status.Idle, m.Status.Phase = false, true, &phaseResting case daemon.EventUsage: m.Usage = evt.Usage if watchedThought { for _, entry := range entries { if entry.Kind == EntryThinking { m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking") } } } case daemon.EventInterrupted: m.Stopping, m.Stopped, m.TurnFailed = false, true, false m.clearAction() m.Status.Running, m.Status.Idle, m.Status.Phase = false, true, &phaseResting } } // stretch is one run of a phase: a thinking or replying spell, or one tool // call, which keeps a single animation from start to end. type stretch struct { mood mood call string } // reseedMood draws a new animation when a new stretch starts, at random, so // the same one may well come up twice. func (m *ChatModel) reseedMood() { now := stretch{mood: m.phaseMood()} if m.Progress != nil { now.call = m.Progress.CallID } if now != m.stretch { m.stretch, m.moodSeed = now, rand.Int64() } } func formatUsage(u *daemon.Usage) string { if u == nil { return "—" } total := 0 if u.TotalTokens != nil { total = *u.TotalTokens } rate := "" if u.TokensPerSecond != nil && *u.TokensPerSecond > 0 { rate = fmt.Sprintf(" · %.1f tok/s", *u.TokensPerSecond) } return formatTokens(total) + " tokens" + rate } func formatTokens(val int) string { switch { case val >= 1_000_000: return fmt.Sprintf("%.1fm", float64(val)/1_000_000.0) case val >= 1_000: return fmt.Sprintf("%.1fk", float64(val)/1_000.0) default: return strconv.Itoa(val) } } func (m ChatModel) padding() int { if m.Width >= 50 { return 2 } return 1 } func (m ChatModel) chatWidth() int { return max(1, m.Width-2*m.padding()) } func (m ChatModel) sidebarWidth() int { for _, glance := range m.Glances { if len(glance.Rows) > 0 { margin := m.chatWidth() - min(100, m.chatWidth()) - 2 if margin >= 16 { return min(32, margin) } break } } return 0 } // intentVerbs name an intent while its call is written, while it runs, and // once it ran. var intentVerbs = map[string]struct{ generating, running, done string }{ "read": {"reading", "reading", "read"}, "write": {"writing", "writing", "wrote"}, "edit": {"editing", "editing", "edited"}, "run": {"preparing", "running", "ran"}, } // actionLabel is the action row for a call, live from its progress and then // from its result, so the row holds the last action until the next begins. // A call that said what it does (read a file, ran a command) is named by // that; any other is named by the call itself, as its settled tool row is. func actionLabel(progress *daemon.ToolProgress, result *HistoryEntry) string { if progress != nil && progress.Intent != nil { verbs := intentVerbs[progress.Intent.Kind] verb := verbs.running switch { case result != nil: verb = verbs.done case progress.Phase == "generating": verb = verbs.generating } label := verb + " " + progress.Intent.Target if result != nil && toolFailed(*result) { label += " · failed" } return label } switch { case result != nil: head, tail := toolRowParts(*result, toolFailed(*result), "") return head + tail case progress.Phase == "generating": return "making a " + progress.Name + " call" case progress.Code != nil && progress.Code.Text != "": return toolSummary(HistoryEntry{ToolName: progress.Name, ToolArgs: map[string]any{"code": progress.Code.Text}}) } return "running " + progress.Name } // renderProgress holds the last tool action in one row; a call still being // generated also shows the newest end of its code. func (m ChatModel) renderProgress() string { width := max(1, m.Renderer.BodyWidth-railWidth) row := oneLine(m.ToolProgressText) if m.Progress != nil && m.Progress.Phase == "generating" && m.Progress.Code != nil { code := m.Progress.Code if text := oneLine(code.Text); text != "" { row += " · " if room := width - ansi.StringWidth(row); room >= 12 && ansi.StringWidth(text) > room { text = ansi.TruncateLeft(text, ansi.StringWidth(text)-room+1, "…") } row += text } } return markChrome + m.Styles.Faint.Render(ansi.Truncate(row, width, "…")) } // renderThought follows the newest line while the thought streams. func (m ChatModel) renderThought() string { header := cmp.Or(thinkingLine(m.transcript.activeText), "thinking") width := max(1, m.Renderer.BodyWidth-railWidth) return markChrome + m.Styles.Faint.Render(ansi.Truncate(header+"…", width, "…")) } func (m ChatModel) statusLine() string { if m.TurnFailed || m.Notices.HasError() { return "Reply failed · see error above" } if m.Stopping { return "stopping…" } // an idle session says nothing unless verbose: the transcript already // ends in the turn's signoff. if m.Stopped { if m.Flags.Tools { return "stopped" } return "" } if m.isSending || len(m.pendingUsers) > 0 && !m.Status.Running { return "preparing" } if m.Progress != nil { if m.Progress.Phase == "generating" { return "generating call" } return "running " + m.Progress.Name } if m.Status.Running && !m.Status.Idle { if m.transcript.activeKind == StreamKindText { return "responding" } if m.Status.Phase != nil { switch *m.Status.Phase { case daemon.PhaseTool: return "running tool" case daemon.PhaseCompacting: return "compacting context" case daemon.PhasePreparing: return "preparing" } } return "thinking" } if m.connecting() { return "connecting…" } if m.Flags.Tools { return "ready" } return "" } // Phase is optional display metadata, not evidence of an unanswered status. func (m ChatModel) connecting() bool { return m.Status.Phase == nil && !m.Status.Running && !m.Status.Idle } // phaseMood is the face class for the phase statusLine names. func (m ChatModel) phaseMood() mood { switch { case m.Stopping: return moodStopping case m.isSending || len(m.pendingUsers) > 0 && !m.Status.Running: return moodPreparing case m.Progress != nil: return moodWorking case m.transcript.activeKind == StreamKindText: return moodResponding case m.Status.Phase != nil: switch *m.Status.Phase { case daemon.PhaseTool: return moodWorking case daemon.PhaseCompacting: return moodCompacting case daemon.PhasePreparing: return moodPreparing } } return moodThinking } // header fits the workspace and the model on one rule without cutting either // short, shedding the least useful parts first: the middle of the path, the // brand, the glance counts from the last, then the path down to its name, // which alone may squeeze the rule to one cell. func (m ChatModel) header(width int) string { workspace := homePath(cmp.Or(m.Workspace, "chat")) model := m.Model if m.Effort != "" { model += ":" + m.Effort } counts := m.glanceCounts() folds := pathFolds(workspace) type layout struct { place string counts int rule int brand bool } var layouts []layout for _, place := range folds { layouts = append(layouts, layout{brand: true, place: place, counts: len(counts), rule: 3}) } for n := len(counts); n >= 0; n-- { layouts = append(layouts, layout{brand: false, place: folds[len(folds)-1], counts: n, rule: 3}) } layouts = append(layouts, layout{brand: false, place: filepath.Base(workspace), counts: 0, rule: 1}) for _, l := range layouts { right := strings.Join(append(counts[:l.counts:l.counts], model), " ") left := l.place if l.brand { left = "✦ " + m.AgentName + " on " + l.place } if lipgloss.Width(left)+lipgloss.Width(right)+l.rule+2 > width { continue } if l.brand { return titleRule(width, located(m.AgentName, l.place), m.Styles.Faint.Render(right)) } return titleRule(width, m.Styles.Muted.Render(l.place), m.Styles.Faint.Render(right)) } return m.Styles.Faint.Render(ansi.Truncate(model, width, "…")) } func (m ChatModel) View() string { width := m.chatWidth() pad := strings.Repeat(" ", m.padding()) var rows []string rows = append(rows, m.header(width), "") for _, n := range m.Notices { if n.Error { rows = append(rows, m.Renderer.errorRow(n.Message)) } else { rows = append(rows, m.Styles.Faint.Render(n.Message)) } } if len(m.Notices) > 0 { rows = append(rows, "") } view := m.Viewport.View() if m.History.Len() == 0 && m.transcript.activeText == "" && m.ToolProgressText == "" { textWidth := max(1, min(m.Renderer.BodyWidth, m.Viewport.Width())-railWidth) var emptyRows []string for _, line := range wrapOrChunkLine("What would you like to work on?", textWidth) { emptyRows = append(emptyRows, m.Renderer.rail(laneNone)+m.Styles.Faint.Render(line)) } view = strings.Join(emptyRows, "\n") } if m.sidebarWidth() > 0 { view = lipgloss.JoinHorizontal(lipgloss.Top, view, " ", m.renderGlances()) } content := strings.Split(view, "\n") for len(content) < m.Viewport.Height() { content = append(content, "") } content = content[:min(len(content), m.Viewport.Height())] if m.dragAnchor != nil { // the selection is in transcript rows; the viewport shows from scrollOffset anchor, head := *m.dragAnchor, m.dragHead anchor.Row, head.Row = anchor.Row-m.scrollOffset, head.Row-m.scrollOffset content = HighlightSelection(content, Selection{Anchor: anchor, Head: head, Gutter: railWidth}) } rows = append(rows, content...) status := m.statusLine() // the face trails the text, so its frames never move anything if m.animating() && !m.TurnFailed && !m.Notices.HasError() { status = m.Styles.Faint.Render(status) + " " + m.Styles.Agent.Render(m.phaseMood().frame(m.moodSeed, m.ProgressFrame)) } if !m.Follow { status = fmt.Sprintf("history · %d rows below · pgdn", max(0, m.scrollLimit-m.scrollOffset)) } if m.AttachedImage != nil { status = daemon.ImageLabel(m.AttachedImage.ImageMetadata) + " attached · esc remove" } if m.CopyStatus != "" { status = m.CopyStatus } statusStyle := m.Styles.Faint if m.TurnFailed || m.Notices.HasError() { statusStyle = m.Styles.Error } rows = append(rows, statusStyle.Render(status)) rows = append(rows, m.Styles.Decor.Render(strings.Repeat("─", width))) if len(m.effortOptions) > 0 { rows = append(rows, "", m.Styles.Bold.Render(ansi.Truncate("Reasoning effort", width, "")), m.effortSelectorView(), m.Styles.Faint.Render(ansi.Truncate("← → choose · enter apply · esc cancel", width, ""))) } else { rows = append(rows, strings.Split(strings.TrimSuffix(m.composerView(), "\n"), "\n")...) if menu := m.CommandMenu.View(m.TextArea.Value()); menu != "" { rows = append(rows, strings.Split(strings.TrimSuffix(menu, "\n"), "\n")...) } } rows = append(rows, m.Styles.Decor.Render(strings.Repeat("─", width))) rows = append(rows, m.renderFooter()) for i, row := range rows { rows[i] = pad + row } return strings.Join(rows, "\n") } func (m ChatModel) composerView() string { ta := m.TextArea if m.waitingForInput() && ta.Value() == "" { ta.Placeholder = "ctrl+g editor" } else { ta.Placeholder = "" } lines := strings.Split(ta.View(), "\n") h := m.promptHeight() if h < len(lines) { lines = lines[:h] } return strings.Join(lines, "\n") } // waitingForInput reports an opened session with no turn in flight, so an // empty composer reads as the agent's cue rather than a stalled turn. func (m ChatModel) waitingForInput() bool { return !m.connecting() && !m.animating() } // glanceCounts counts each glance with rows that the sidebar leaves out: all // of them without a sidebar, the ones after its first with one. func (m ChatModel) glanceCounts() []string { var counts []string for _, g := range m.Glances { if len(g.Rows) > 0 { counts = append(counts, fmt.Sprintf("%s %d", g.Title, len(g.Rows))) } } if len(counts) > 0 && m.sidebarWidth() > 0 { counts = counts[1:] } return counts } func (m ChatModel) renderGlances() string { for _, g := range m.Glances { if len(g.Rows) == 0 { continue } room := max(0, min(m.Viewport.Height(), 12)-1) shown := min(room, len(g.Rows)) if len(g.Rows) > room { shown = max(0, room-1) } rows := []string{m.Styles.Faint.Render(fmt.Sprintf("%s · %d", g.Title, len(g.Rows)))} for _, item := range g.Rows[:shown] { mark, style := "○", m.Styles.Faint switch item.Tone { case ToneActive: mark, style = "●", m.Styles.Success case ToneWarning: mark, style = "!", m.Styles.Warning case ToneMuted: mark = "✓" } label := item.Text if item.ID != "" { label = "#" + item.ID + " " + item.Text } rows = append(rows, style.Render(mark)+" "+label) } if shown < len(g.Rows) { rows = append(rows, m.Styles.Faint.Render(fmt.Sprintf("+%d more", len(g.Rows)-shown))) } return lipgloss.NewStyle().Width(m.sidebarWidth()).Render(strings.Join(rows, "\n")) } return "" } func (m ChatModel) renderFooter() string { width := m.chatWidth() right, compact := m.contextStat() commands := hint{"/", "commands"} candidates := []struct { right string left []hint }{ {left: []hint{commands, {"shift+↑↓", "your messages"}, {"ctrl+j", "diffs"}, {"ctrl+o", "agents"}}, right: right}, {left: []hint{commands, {"ctrl+j", "diffs"}, {"ctrl+o", "agents"}}, right: right}, {left: []hint{commands, {"ctrl+o", "agents"}}, right: compact}, {left: []hint{commands, {"ctrl+j", "diffs"}}, right: compact}, {left: []hint{commands}, right: compact}, {left: []hint{{"/", ""}}, right: compact}, } for _, c := range candidates { left := keyHints(c.left...) if gap := width - lipgloss.Width(left) - lipgloss.Width(c.right); gap > 0 { return left + strings.Repeat(" ", gap) + c.right } } return ansi.Truncate(keyHints(commands), width, "…") } // contextStat is how much of the prompt was read from cache, fading as the // provider lets its cache go, and how full the context is: "392k/396k cached // (38%)", with a shorter form for narrow footers. The share turns yellow near // the window, where compaction starts. func (m ChatModel) contextStat() (full, short string) { usage := m.Usage if usage == nil || usage.Model != "" && m.Model != "" && usage.Model != m.Model { return m.Styles.Faint.Render("—/— cached"), m.Styles.Faint.Render("—/—") } count := func(n *int) string { if n == nil || *n < 0 { return "—" } return shortCount(*n) } ratio := m.Styles.Muted.Render(count(cachedNow(usage, time.Now())) + "/" + count(usage.PromptTokens)) share := "" if total := contextTokens(usage); total != nil && m.window != nil && m.windowModel != nil && *m.windowModel == usage.Model { pct := int(math.Round(100 * float64(*total) / float64(*m.window))) style := m.Styles.Faint if pct >= 90 { style = m.Styles.Warning } share = " " + style.Render(fmt.Sprintf("(%d%%)", pct)) } return ratio + m.Styles.Faint.Render(" cached") + share, ratio + share } // cachedNow is what a request would read from cache at now: the measured // count until the cache starts to fade, then each step it has reached. func cachedNow(u *daemon.Usage, now time.Time) *int { cached := u.CachedPromptTokens for _, step := range u.CacheFade { if step.At > now.UnixMilli() { break } cached = step.Cached } return cached } // contextTokens is what the context holds after a reply: the prompt and the // completion, when the provider did not report a total. func contextTokens(u *daemon.Usage) *int { if u.TotalTokens != nil && *u.TotalTokens >= 0 { return u.TotalTokens } if u.PromptTokens == nil || *u.PromptTokens < 0 { return nil } total := *u.PromptTokens if u.CompletionTokens != nil && *u.CompletionTokens > 0 { total += *u.CompletionTokens } return &total } // shortCount keeps token counts to about three digits: 812, 4.2k, 392k, 1.2m. func shortCount(n int) string { switch { case n < 1_000: return strconv.Itoa(n) case n < 10_000: return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0") + "k" case n < 999_500: return fmt.Sprintf("%dk", int(math.Round(float64(n)/1_000))) default: return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "m" } }