Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067package tui
import ( "albedo/cli/internal/daemon" "context" "errors" "fmt" "math" "math/rand/v2" "regexp" "slices" "strconv" "strings" "time"
"github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi")
type ChatBackToSessionsMsg struct{}type ChatQuitMsg struct{}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 Generation int64 Event daemon.StreamEvent}
type ChatProgressTickMsg struct { SessionID string Generation int64}
type ChatClearCopyStatusMsg struct { SessionID string Generation int64 Revision uint64}
type ChatStatusMsg struct { SessionID string Generation int64 Revision uint64 Status *daemon.AgentStatus Err error}
// ChatWindowMsg carries the context window for the model a usage event named.type ChatWindowMsg struct { SessionID string Generation int64 Model string Tokens *int Err error}
type ChatStatusPollMsg struct { SessionID string Generation int64}
type ChatStreamClosedMsg struct { SessionID string Generation int64}
type ChatTurnSentMsg struct { SessionID string Generation int64 Prompt string Image *daemon.ImageAttachment OK bool Queued bool Err error}
type ChatInterruptMsg struct { SessionID string Generation int64 Interrupted bool Err error}
type ChatReplaceWorkspaceMsg struct { SessionID string Generation int64 Workspace string Err error}
type WorkspaceRecoveryState struct { Missing string Replacement string Prompt string Image *daemon.ImageAttachment Saving bool Error string}
type ActiveStreamKind string
const ( StreamKindNone ActiveStreamKind = "" StreamKindText ActiveStreamKind = "text" StreamKindThinking ActiveStreamKind = "thinking")
type PendingUserTurn struct { Text string Image *daemon.ImageAttachment Queued bool At int64}
const ( MaxLiveStreamBytes = 64 * 1024 MaxPendingUsers = 10 MaxSettledLines = 1000 MaxSettledLinesBytes = 256 * 1024 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`)
func wrapOrChunkLine(line string, width int) []string { if width <= 0 { width = 80 } // 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}
type ChatModel struct { SessionID string Generation int64 AgentName string Workspace string Model string Provider string Client *daemon.ChatClient History *BoundedHistory Renderer TranscriptRenderer Viewport viewport.Model TextArea textarea.Model CommandMenu CommandMenuModel Flags DisplayFlags Follow bool TurnFailed bool Stopping bool Stopped bool Status daemon.AgentStatus Usage *daemon.Usage // window is the context window of windowModel, read once per model so // the footer can say how full the context is. window *int windowModel *string // stretch is the phase run the status face animates; moodSeed picks its // animation. stretch stretch moodSeed int64 Glances []PageGlance AttachedImage *daemon.ImageAttachment Notice string CopyStatus string copyStatusRevision uint64 ErrorNotice string ExternalNotice string ExternalError string ToolProgressText string Progress *daemon.ToolProgress ProgressFrame int animationActive bool statusRevision uint64 Width int Height int Styles Styles
settledLines []string settledLinesBytes int64 droppedSettledLines int
scrollOffset int scrollLimit int
activeKind ActiveStreamKind activeText string
streamedHash uint64 streamedLen int64
pendingUsers []PendingUserTurn isSending bool interruptDeferred bool // sentHere keeps replayed errors from an earlier attach from reopening // /login; loginRequested carries a sign-in failure out of handleStreamEvent. sentHere bool loginRequested bool
WorkspaceRecovery *WorkspaceRecoveryState RecoveryInput textinput.Model
dragAnchor *Point dragHead Point
turn *openTurn // userRows are the settled rows where your messages start, and // settledOffset is how many notice rows sit above the settled rows. userRows []int settledOffset int
streamCtx context.Context streamCancel context.CancelFunc eventChan chan daemon.StreamEvent}
func NewChatModel(session *daemon.Session, client *daemon.ChatClient) ChatModel { ta := textarea.New() ta.Placeholder = "" ta.Prompt = promptMark ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetKeys("enter", "ctrl+m", "alt+enter", "shift+enter") ta.SetHeight(1) ta.FocusedStyle.Prompt = DefaultStyles.Prompt ta.FocusedStyle.CursorLine = lipgloss.NewStyle() ta.Cursor.Style = DefaultStyles.Cursor ta.Cursor.SetMode(cursor.CursorStatic) ta.Focus()
vp := viewport.New(80, 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, Provider: session.Provider, Client: client, History: bh, Renderer: NewTranscriptRenderer(), Viewport: vp, TextArea: ta, CommandMenu: NewCommandMenuModel(), Flags: DisplayFlags{ Thinking: false, Tools: false, Compaction: false, }, Follow: true, Styles: DefaultStyles, streamedHash: fnvOffset64, 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) chromeRows() int { menu := min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) if m.WorkspaceRecovery != nil { menu = 2 } notice := 0 if m.Notice != "" || m.ErrorNotice != "" || m.ExternalNotice != "" || m.ExternalError != "" { notice = 2 } return menu + notice}
func (m *ChatModel) SetSize(width, height int) { if m == nil || m.History == nil { return } if width <= 0 { width = 1 } if height <= 0 { height = 1 }
m.Width = width m.Height = height
padding := m.padding() available := max(1, width-2*padding) chromeRows := m.chromeRows() transcriptWidth := available if m.sidebarWidth() > 0 { transcriptWidth -= m.sidebarWidth() + 2 } m.Viewport.Width = transcriptWidth m.Renderer.BodyWidth = min(100, available) m.Viewport.Height = max(1, height-6-chromeRows) m.TextArea.SetHeight(1) m.TextArea.SetWidth(max(1, available-2)) m.RecoveryInput.Width = max(1, available-16)
m.rebuildSettledLines() m.refreshViewportContent()}
func (m *ChatModel) rebuildSettledLines() { m.settledLines = nil m.settledLinesBytes = 0 m.userRows = nil entries := m.History.Entries() for i := range entries { m.appendBlock(entries[:i], entries[i]) }}
// appendBlock renders entry after the settled entries before it.func (m *ChatModel) appendBlock(before []HistoryEntry, entry HistoryEntry) { rows, head := m.Renderer.Block(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()}
func (m *ChatModel) trimSettledLines() { dropped := 0 for len(m.settledLines) > MaxSettledLines || m.settledLinesBytes > MaxSettledLinesBytes { if len(m.settledLines) == 0 { 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 }}
func (m *ChatModel) appendSettledEntry(entry HistoryEntry) { before := m.History.Entries() m.appendBlock(before, entry) m.History.Append(entry)}
func (m *ChatModel) settleActiveStream() { if m.activeKind == StreamKindNone || m.activeText == "" { m.activeKind = StreamKindNone m.activeText = "" return } var kind EntryKind = EntryAssistant if m.activeKind == StreamKindThinking { kind = EntryThinking } entry := HistoryEntry{ Kind: kind, Speaker: m.AgentName, Text: m.activeText, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry) m.activeKind = StreamKindNone m.activeText = ""}
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 } var allLines []string
indent := m.Renderer.rail(laneNone) if notice := m.History.TruncationNotice(); notice != "" { allLines = append(allLines, indent+m.Styles.Warning.Render(notice), "") } if m.droppedSettledLines > 0 { allLines = append(allLines, indent+m.Styles.Faint.Render(fmt.Sprintf("[%d scrollback lines truncated]", m.droppedSettledLines)), "") }
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 m.activeKind != StreamKindNone && m.activeText != "" { var kind EntryKind = EntryAssistant if m.activeKind == StreamKindThinking { kind = EntryThinking } activeEntry := HistoryEntry{Kind: kind, Speaker: m.AgentName, Text: m.activeText} rows, _ := m.Renderer.Block(m.History.Entries(), activeEntry, m.Flags) allLines = append(allLines, rows...) last, stacks = laneOf(activeEntry), false }
if m.ToolProgressText != "" { // the live row stacks under tool rows like the row it settles into if len(allLines) > 0 && !stacks { allLines = append(allLines, strings.TrimRight(m.Renderer.rail(joint(last, laneBusy)), " ")) } allLines = append(allLines, m.Renderer.rail(laneBusy)+m.renderProgress()) } allLines = append(allLines, m.pendingRows()...)
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] m.Viewport.SetContent(strings.Join(visibleSlice, "\n")) m.Viewport.GotoBottom() } 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")) 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.activeKind != StreamKindNone && m.activeText != "" { before = append(before, HistoryEntry{Kind: EntryAssistant, Speaker: m.AgentName}) } if m.ToolProgressText != "" { before = append(before, HistoryEntry{Kind: EntryTool, 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(80*time.Millisecond, func(time.Time) tea.Msg { return ChatProgressTickMsg{SessionID: id, Generation: generation} })}
func (m ChatModel) waitForNextEvent() tea.Cmd { sessID := m.SessionID gen := m.Generation ch := m.eventChan ctx := 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 { if m.Client == nil { return nil } client := m.Client ctx := m.streamCtx ch := m.eventChan
go func() { defer close(ch) for { if ctx.Err() != nil { return } err := client.Stream(ctx, nil, func(evt daemon.StreamEvent) error { select { case ch <- evt: return nil case <-ctx.Done(): return ctx.Err() } }) if ctx.Err() != nil { return } _ = 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 { if m.Client == nil { return nil } 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.Client == nil || 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} }}
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())}
func (m ChatModel) Update(msg tea.Msg) (ChatModel, tea.Cmd) { var cmds []tea.Cmd
switch msg := msg.(type) { case tea.KeyMsg: if m.WorkspaceRecovery != nil { if msg.Type == tea.KeyEsc { prompt := m.WorkspaceRecovery.Prompt img := m.WorkspaceRecovery.Image m.WorkspaceRecovery = nil if m.TextArea.Value() == "" { m.TextArea.SetValue(prompt) } if m.AttachedImage == nil && img != nil { m.AttachedImage = img } m.refreshViewportContent() return m, nil } if msg.Type == tea.KeyEnter { m.RecoveryInput.CursorStart() replacement := strings.TrimSpace(m.RecoveryInput.Value()) if replacement != "" && !m.WorkspaceRecovery.Saving { m.WorkspaceRecovery.Saving = true m.WorkspaceRecovery.Replacement = replacement m.WorkspaceRecovery.Error = "" return m, m.replaceWorkspaceCmd(replacement) } return m, nil } var riCmd tea.Cmd m.RecoveryInput, riCmd = m.RecoveryInput.Update(msg) return m, riCmd }
if msg.Type == tea.KeyLeft && m.TextArea.Value() == "" { return m, func() tea.Msg { return ChatBackToSessionsMsg{} } }
if msg.Type == tea.KeyCtrlC { if m.streamCancel != nil { m.streamCancel() } return m, func() tea.Msg { return ChatQuitMsg{} } } if msg.Type == tea.KeyCtrlN { return m, func() tea.Msg { return ChatNewSessionMsg{} } } if msg.Type == tea.KeyEsc && m.dragAnchor != nil { m.dragAnchor = nil 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.SetSize(m.Width, m.Height) return m, tea.Batch(cmds...) }
if msg.Type == tea.KeyEsc { 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 if m.Client != nil { cmds = append(cmds, m.interruptCmd()) } return m, tea.Batch(cmds...) } }
if msg.Type == tea.KeyCtrlV { return m, PasteClipboardImageCmd(m.SessionID, m.Generation) }
if msg.Type == tea.KeyPgUp { m.scrollBy(-m.Viewport.Height) return m, nil } if msg.Type == tea.KeyPgDown { m.scrollBy(m.Viewport.Height) return m, nil } if msg.Type == tea.KeyUp && m.TextArea.Line() == 0 { m.scrollBy(-1) return m, nil } if msg.Type == tea.KeyDown && m.TextArea.Line() >= m.TextArea.LineCount()-1 { m.scrollBy(1) return m, nil } if msg.Type == tea.KeyShiftUp || msg.Type == tea.KeyShiftDown { m.jumpToYou(msg.Type == tea.KeyShiftUp) return m, nil } if msg.Type == tea.KeyCtrlHome { m.Follow = false m.scrollOffset = 0 m.refreshViewportContent() return m, nil } if msg.Type == tea.KeyCtrlEnd { m.Follow = true m.refreshViewportContent() return m, nil }
if msg.Type == tea.KeyCtrlJ { m.Flags.Diffs = !m.Flags.Diffs m.rebuildSettledLines() m.refreshViewportContent() return m, nil } if msg.Type == tea.KeyCtrlK { m.Flags.Compaction = !m.Flags.Compaction m.rebuildSettledLines() m.refreshViewportContent() return m, nil }
if msg.Type == tea.KeyEnter && !msg.Alt { trimmed := strings.TrimSpace(m.TextArea.Value()) if trimmed != "" { m.TextArea.Reset() m.submitInput(trimmed, &cmds) return m, tea.Batch(cmds...) } return m, nil }
case tea.MouseMsg: firstRow := 2 if m.Notice != "" || m.ErrorNotice != "" || m.ExternalNotice != "" || m.ExternalError != "" { firstRow += 2 } point := func() Point { return Point{Row: max(0, min(m.Viewport.Height-1, msg.Y-firstRow)), Col: max(0, min(m.Viewport.Width, msg.X-m.padding()))} } if msg.Action == tea.MouseActionRelease && m.dragAnchor != nil { m.dragHead = point() sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth} m.dragAnchor = nil if !sel.IsEmpty() { lines := strings.Split(m.Viewport.View(), "\n") if text := SelectedText(lines, sel); text != "" { if err := CopyText(text); err != nil { m.CopyStatus = "copy failed: " + err.Error() } else { m.CopyStatus = "copied" } m.copyStatusRevision++ return m, m.clearCopyStatusCmd() } } return m, nil } if msg.Action == tea.MouseActionMotion && m.dragAnchor != nil { m.dragHead = point() return m, nil } if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { if msg.Y < firstRow || msg.Y >= firstRow+m.Viewport.Height { return m, nil } pt := point() m.dragAnchor = &pt m.dragHead = pt return m, nil } switch msg.Button { case tea.MouseButtonWheelUp: m.scrollBy(-3) return m, nil case tea.MouseButtonWheelDown: m.scrollBy(3) 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 && msg.Err.Error() != "no image in clipboard" { m.ErrorNotice = fmt.Sprintf("image paste failed: %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() if !m.Status.Running || m.Status.Idle { m.Progress = nil m.ToolProgressText = "" m.settleActiveStream() if m.turn != nil && m.turn.begun() { m.closeTurn(false) } 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() if m.loginRequested { m.loginRequested = false return m, tea.Batch(m.waitForNextEvent(), func() tea.Msg { return ChatOpenLoginMsg{} }) } return m, tea.Batch(m.waitForNextEvent(), m.startAnimation(), m.windowCmd())
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 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 } m.ProgressFrame++ m.refreshViewportContent() return m, m.progressTickCmd()
case ChatStreamClosedMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } 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 var wsErr *daemon.WorkspaceMissingError if errors.As(msg.Err, &wsErr) { m.Status.Running = false m.Status.Idle = true 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 } ti := textinput.New() ti.SetValue(wsErr.Workspace) ti.Focus() ti.Prompt = "new workspace › " ti.PromptStyle = m.Styles.Prompt ti.Cursor.Style = DefaultStyles.Cursor ti.Cursor.SetMode(cursor.CursorStatic) m.RecoveryInput = ti m.WorkspaceRecovery = &WorkspaceRecoveryState{ Missing: wsErr.Workspace, Replacement: wsErr.Workspace, Prompt: msg.Prompt, Image: msg.Image, } m.refreshViewportContent() return m, nil }
m.ErrorNotice = fmt.Sprintf("send failed: %v", msg.Err) if msg.Queued { m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: "message not queued: " + msg.Err.Error()}) } else { m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: msg.Err.Error()}) } if m.TextArea.Value() == "" { m.TextArea.SetValue(msg.Prompt) } if m.AttachedImage == nil && msg.Image != nil { m.AttachedImage = msg.Image } if len(m.pendingUsers) > 0 { m.pendingUsers = m.pendingUsers[1:] } m.refreshViewportContent() return m, nil }
if msg.Queued { for i := range m.pendingUsers { if m.pendingUsers[i].Text == msg.Prompt && !m.pendingUsers[i].Queued { m.pendingUsers[i].Queued = true break } } }
// 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 if m.Client != nil { cmds = append(cmds, m.interruptCmd()) } } return m, tea.Batch(cmds...)
case ChatReplaceWorkspaceMsg: if msg.SessionID != m.SessionID || msg.Generation != m.Generation { return m, nil } if m.WorkspaceRecovery == nil { return m, nil } if msg.Err != nil { m.WorkspaceRecovery.Saving = false m.WorkspaceRecovery.Error = msg.Err.Error() return m, nil } m.Workspace = msg.Workspace prompt := m.WorkspaceRecovery.Prompt img := m.WorkspaceRecovery.Image m.WorkspaceRecovery = nil
cmds = append(cmds, func() tea.Msg { return ChatWorkspaceChangedMsg{Workspace: msg.Workspace} }) // Set original image BEFORE submitInput so submitInput captures and sends it! if img != nil && m.AttachedImage == nil { m.AttachedImage = img } m.TextArea.Reset() m.submitInput(prompt, &cmds) return m, tea.Batch(cmds...)
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 turn: " + msg.Err.Error()}) } } return m, nil }
var taCmd tea.Cmd oldMenuRows := min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) m.TextArea, taCmd = m.TextArea.Update(msg) if min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) != oldMenuRows { m.SetSize(m.Width, m.Height) } cmds = append(cmds, taCmd)
return m, tea.Batch(cmds...)}
func (m *ChatModel) replaceWorkspaceCmd(newWorkspace string) tea.Cmd { client := m.Client sessID := m.SessionID gen := m.Generation
return func() tea.Msg { if client == nil { return ChatReplaceWorkspaceMsg{SessionID: sessID, Generation: gen, Err: fmt.Errorf("no client available")} } upd, err := client.ReplaceWorkspace(context.Background(), newWorkspace) if err != nil { return ChatReplaceWorkspaceMsg{SessionID: sessID, Generation: gen, Err: err} } return ChatReplaceWorkspaceMsg{SessionID: sessID, Generation: gen, Workspace: upd.Workspace} }}
func (m *ChatModel) sendTurnCmd(content string, image *daemon.ImageAttachment) tea.Cmd { client := m.Client sessID := m.SessionID gen := m.Generation
return func() tea.Msg { if client == nil { return ChatTurnSentMsg{SessionID: sessID, Generation: gen, Prompt: content, Image: image, Err: fmt.Errorf("no client available")} } res, err := client.Send(context.Background(), content, image) if err != nil { return ChatTurnSentMsg{SessionID: sessID, Generation: gen, Prompt: content, Image: image, Err: err} } return ChatTurnSentMsg{SessionID: sessID, Generation: gen, Prompt: content, Image: image, OK: res.OK, Queued: res.Queued} }}
func (m *ChatModel) interruptCmd() tea.Cmd { client := m.Client sessID := m.SessionID gen := m.Generation
return func() tea.Msg { if client == nil { return ChatInterruptMsg{SessionID: sessID, Generation: gen, Err: fmt.Errorf("no client available")} } ok, err := client.Interrupt(context.Background()) return ChatInterruptMsg{SessionID: sessID, Generation: gen, Interrupted: ok, Err: err} }}
func (m *ChatModel) isRecognizedCommand(input string) bool { trimmed := strings.TrimSpace(input) if !strings.HasPrefix(trimmed, "/") { return false } parts := strings.Fields(trimmed) if len(parts) == 0 { return false } token := parts[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": return true } for _, cmd := range m.CommandMenu.Catalog { if cmd.Name == token { return true } } return false}
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.ErrorNotice = "too many pending turns; wait for current turn to complete" m.refreshViewportContent() return }
img := m.AttachedImage m.AttachedImage = nil m.pendingUsers = append(m.pendingUsers, PendingUserTurn{ Text: input, Image: img, At: time.Now().UnixMilli(), }) m.isSending = true m.reseedMood() m.sentHere = true m.Follow = true m.refreshViewportContent()
*cmds = append(*cmds, m.sendTurnCmd(input, img), m.startAnimation())}
func (m *ChatModel) handleSubmittedCommand(input string, cmds *[]tea.Cmd) { m.TextArea.Reset() trimmed := strings.TrimSpace(input)
switch { case trimmed == "/a" || trimmed == "/agents" || trimmed == "/sessions": *cmds = append(*cmds, func() tea.Msg { return ChatBackToSessionsMsg{} }) case trimmed == "/q" || trimmed == "/quit" || trimmed == "/exit": if m.streamCancel != nil { m.streamCancel() } *cmds = append(*cmds, func() tea.Msg { return ChatQuitMsg{} }) case trimmed == "/new": *cmds = append(*cmds, func() tea.Msg { return ChatNewSessionMsg{} }) case trimmed == "/model": *cmds = append(*cmds, func() tea.Msg { return ChatOpenModelPickerMsg{} }) case strings.HasPrefix(trimmed, "/model "): modelArg := strings.TrimSpace(trimmed[7:]) *cmds = append(*cmds, func() tea.Msg { return ChatExecuteCommandMsg{Name: "/model", Args: modelArg} }) case trimmed == "/extensions" || trimmed == "/plugins": *cmds = append(*cmds, func() tea.Msg { return ChatOpenExtensionPickerMsg{} }) case trimmed == "/tree": *cmds = append(*cmds, func() tea.Msg { return ChatOpenTreePickerMsg{} }) case trimmed == "/context": *cmds = append(*cmds, func() tea.Msg { return ChatOpenContextInspectorMsg{} }) case trimmed == "/t" || trimmed == "/thinking": m.Flags.Thinking = !m.Flags.Thinking m.rebuildSettledLines() m.refreshViewportContent() case trimmed == "/v" || trimmed == "/verbose": m.Flags.Tools = !m.Flags.Tools m.rebuildSettledLines() m.refreshViewportContent() case trimmed == "/status": statusText := fmt.Sprintf("session: %s\nworkspace: %s\nmodel: %s", m.SessionID, m.Workspace, m.Model) if m.Usage != nil { statusText += fmt.Sprintf("\ntokens: %s", formatUsage(m.Usage)) } m.appendSettledEntry(HistoryEntry{ Kind: EntryNote, Text: statusText, }) m.refreshViewportContent() case trimmed == "/skills" || trimmed == "/instructions" || trimmed == "/mcp": kind := strings.TrimPrefix(trimmed, "/") *cmds = append(*cmds, func() tea.Msg { return ChatOpenCapabilityPageMsg{Kind: kind} }) case strings.HasPrefix(trimmed, "/login"): name := strings.TrimSpace(strings.TrimPrefix(trimmed, "/login")) *cmds = append(*cmds, func() tea.Msg { return ChatOpenLoginMsg{Name: name} }) default: for _, cmd := range m.CommandMenu.Catalog { if cmd.Name == trimmed && cmd.Page != nil && *cmd.Page { *cmds = append(*cmds, func() tea.Msg { return ChatOpenPageMsg{Command: trimmed} }) return } } parts := strings.SplitN(trimmed, " ", 2) cmdName := parts[0] cmdArgs := "" if len(parts) > 1 { cmdArgs = parts[1] } *cmds = append(*cmds, func() tea.Msg { return ChatExecuteCommandMsg{Name: cmdName, Args: cmdArgs} }) }}
func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { defer m.reseedMood() if evt.Replayed { // the snapshot rebuilds the transcript; whether albedo is working // now is for /status to say, so reopening a session never flashes // the phase its last turn ended in defer func(live daemon.AgentStatus) { m.Status = live }(m.Status) } if evt.Type != daemon.EventToolProgress { m.Progress = nil } switch evt.Type { case daemon.EventReset: m.settleActiveStream() m.History.Clear() m.settledLines = nil m.settledLinesBytes = 0 m.droppedSettledLines = 0 m.activeKind = StreamKindNone m.activeText = "" m.streamedHash = fnvOffset64 m.streamedLen = 0 m.ToolProgressText = "" m.Usage = nil m.Notice = "" m.ErrorNotice = "" m.TurnFailed = false m.Stopping = false m.Stopped = false m.Follow = true m.scrollOffset = 0 m.pendingUsers = nil m.turn = nil
case daemon.EventRetry: m.activeKind = StreamKindNone m.activeText = "" m.streamedHash = fnvOffset64 m.streamedLen = 0 m.ToolProgressText = ""
case daemon.EventUser: m.Stopped = false m.TurnFailed = false m.settleActiveStream()
if m.Client != nil && evt.ClientID == m.Client.ClientID() { for i, p := range m.pendingUsers { if p.Text == evt.Text { m.pendingUsers = append(m.pendingUsers[:i], m.pendingUsers[i+1:]...) break } } }
speaker := "You" if evt.Source != "" && evt.Source != "chat" { speaker = evt.Source } var ts int64 if evt.Timestamp != nil && *evt.Timestamp > 0 { ts = *evt.Timestamp } else if evt.TriggeredAt != "" { if parsedT, err := time.Parse(time.RFC3339, evt.TriggeredAt); err == nil { ts = parsedT.UnixMilli() } } entry := HistoryEntry{ Kind: EntryUser, Speaker: speaker, Text: evt.Text, ClientID: evt.ClientID, Timestamp: ts, } // Your message starts a turn and closes the one before it. Another // source only opens a turn when none is in flight. opens := m.turn == nil || speaker == "You" if opens { m.closeTurn(false) } m.appendSettledEntry(entry) if opens { if ts == 0 { ts = time.Now().UnixMilli() } m.turn = &openTurn{start: ts, last: ts} }
case daemon.EventText: m.turnIsLive() m.ToolProgressText = "" m.Status.Running = true m.Status.Idle = false phase := daemon.PhaseReasoning m.Status.Phase = &phase
if m.activeKind != StreamKindText { m.settleActiveStream() m.activeKind = StreamKindText } m.activeText += evt.Text m.streamedHash = fnv1a(m.streamedHash, evt.Text) m.streamedLen += int64(len(evt.Text))
if len(m.activeText) > MaxLiveStreamBytes { m.settleActiveStream() m.activeKind = StreamKindText }
case daemon.EventThinking: m.ToolProgressText = "" m.Status.Running = true m.Status.Idle = false phase := daemon.PhaseReasoning m.Status.Phase = &phase
if m.activeKind != StreamKindThinking { m.settleActiveStream() m.activeKind = StreamKindThinking } m.activeText += evt.Text
if len(m.activeText) > MaxLiveStreamBytes { m.settleActiveStream() m.activeKind = StreamKindThinking }
case daemon.EventToolProgress: m.turnIsLive() m.Progress = evt.Progress if evt.Progress != nil { m.settleActiveStream() m.Status.Running = true m.Status.Idle = false phase := daemon.PhaseTool m.Status.Phase = &phase m.ToolProgressText = progressLabel(*evt.Progress) } else { m.ToolProgressText = "" }
case daemon.EventTool: m.settleActiveStream() m.ToolProgressText = "" m.Status.Running = true m.Status.Idle = false
entry := HistoryEntry{ Kind: EntryTool, ToolName: evt.ToolName, ToolArgs: evt.ToolArgs, ToolResult: evt.ToolResult, ToolTrace: evt.ToolTrace, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry) if m.turn != nil { m.turn.tools++ m.turn.touch(evt.Timestamp) }
case daemon.EventMessage: m.ToolProgressText = ""
targetHash := fnv1a(fnvOffset64, evt.Text) isDuplicate := (m.streamedLen == int64(len(evt.Text))) && (m.streamedHash == targetHash) m.streamedHash = fnvOffset64 m.streamedLen = 0
m.settleActiveStream()
if isDuplicate { return }
var ts int64 if evt.Timestamp != nil && *evt.Timestamp > 0 { ts = *evt.Timestamp } entry := HistoryEntry{ Kind: EntryAssistant, Speaker: m.AgentName, Text: evt.Text, Timestamp: ts, } m.appendSettledEntry(entry) if m.turn != nil { m.turn.touch(evt.Timestamp) }
case daemon.EventNote: m.settleActiveStream() entry := HistoryEntry{ Kind: EntryNote, Text: evt.Text, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry)
case daemon.EventError: m.TurnFailed = true m.settleActiveStream() m.ToolProgressText = "" m.Status.Running = false m.Status.Idle = true phase := daemon.PhaseResting m.Status.Phase = &phase
entry := HistoryEntry{ Kind: EntryError, Text: evt.Text, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry) if m.turn != nil { m.turn.failed = true m.closeTurn(false) } // The daemon ends errors that only a new sign-in can fix with "run /login". if m.sentHere && strings.HasSuffix(evt.Text, "run /login") { m.loginRequested = true }
case daemon.EventCompacted: m.settleActiveStream() m.ToolProgressText = ""
entry := HistoryEntry{ Kind: EntryCompacted, Text: evt.Summary, Evicted: evt.Evicted, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry)
case daemon.EventUsage: m.settleActiveStream() m.Usage = evt.Usage
case daemon.EventInterrupted: m.Stopping = false m.Stopped = true m.TurnFailed = false m.settleActiveStream() m.ToolProgressText = "" m.Status.Running = false m.Status.Idle = true phase := daemon.PhaseResting m.Status.Phase = &phase
if m.turn != nil { m.closeTurn(true) return } m.appendSettledEntry(HistoryEntry{ Kind: EntryNote, Text: "stopped by you", Timestamp: time.Now().UnixMilli(), }) }}
// openTurn follows the turn in flight until its signoff.type openTurn struct { // start and last are daemon timestamps in milliseconds. start, last int64 tools int // live marks a turn streamed to this client, which ends now rather // than at its last replayed event. live bool failed bool}
func (t *openTurn) touch(ts *int64) { if ts != nil && *ts > t.last { t.last = *ts }}
// begun reports whether the turn has done anything, so an idle status// that races its first event cannot sign it off early.func (t *openTurn) begun() bool { return t.live || t.tools > 0 || t.last > t.start}
func (m *ChatModel) turnIsLive() { if m.turn != nil { m.turn.live = true }}
// 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() }}
// closeTurn signs off the turn in flight, if there is one.func (m *ChatModel) closeTurn(stopped bool) { t := m.turn if t == nil { return } m.turn = nil end := t.last if t.live { end = max(end, time.Now().UnixMilli()) } elapsed := end - t.start m.appendSettledEntry(HistoryEntry{ Kind: EntryTurnEnd, Mood: outcome(t.failed, stopped, elapsed), ElapsedMs: elapsed, Tools: t.tools, Timestamp: end, })}
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 fmt.Sprintf("%s tokens%s", formatTokens(total), rate)}
func formatTokens(val int) string { if val >= 1_000_000 { return fmt.Sprintf("%.1fm", float64(val)/1_000_000.0) } if val >= 1_000 { return fmt.Sprintf("%.1fk", float64(val)/1_000.0) } return fmt.Sprintf("%d", 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}
func progressLabel(progress daemon.ToolProgress) string { if intent := progress.Intent; intent != nil { verbs := map[string]string{"write": "writing", "edit": "editing", "read": "reading", "run": "running"} verb := verbs[intent.Kind] if intent.Kind == "run" && progress.Phase == "generating" { verb = "preparing" } return verb + " " + intent.Target } if progress.Phase == "generating" { return "making a " + progress.Name + " call" } return "running " + progress.Name}
// renderProgress is the live row for the tool call in flight. It is shaped// like the tool row it settles into, and code being written shows its// newest end.func (m ChatModel) renderProgress() string { if m.Progress == nil { return "" } width := max(1, m.Renderer.BodyWidth-railWidth) row := oneLine(m.ToolProgressText) if code := m.Progress.Code; m.Progress.Phase == "generating" && code != nil { 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 m.Styles.Faint.Render(ansi.Truncate(row, width, "…"))}
func (m ChatModel) statusLine() string { if m.TurnFailed || m.ErrorNotice != "" || m.ExternalError != "" { return "turn 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 { return m.verbose("stopped") } 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.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.Status.Phase == nil && m.Client != nil { return "connecting…" } if m.Status.Phase == nil { return "opening session…" } return m.verbose("ready")}
func (m ChatModel) verbose(status string) string { if m.Flags.Tools { return status } return ""}
// 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.activeKind == StreamKindText: return moodResponding case m.Status.Phase != nil && *m.Status.Phase == daemon.PhaseTool: return moodWorking case m.Status.Phase != nil && *m.Status.Phase == daemon.PhaseCompacting: return moodCompacting case m.Status.Phase != nil && *m.Status.Phase == daemon.PhasePreparing: return moodPreparing } return moodThinking}
func truncateMiddle(text string, width int) string { if width <= 0 { return "" } runes := []rune(text) if len(runes) <= width { return text } if width == 1 { return "…" } left := (width - 1 + 1) / 2 return string(runes[:left]) + "…" + string(runes[len(runes)-(width-1-left):])}
func (m ChatModel) View() string { width := m.chatWidth() pad := strings.Repeat(" ", m.padding()) var rows []string workspace := m.Workspace if workspace == "" { workspace = "chat" } model := m.Model var glance *PageGlance for i := range m.Glances { if len(m.Glances[i].Rows) > 0 { glance = &m.Glances[i] break } } right := model if glance != nil && width-100 < 26 { right = fmt.Sprintf("%s %d", glance.Title, len(glance.Rows)) + " " + right } room := width - lipgloss.Width("✦ "+m.AgentName+" on ") - lipgloss.Width(right) - 5 header := titleRule(width, located(m.AgentName, truncateMiddle(homePath(workspace), max(1, room))), m.Styles.Faint.Render(right)) rows = append(rows, header, "") errorNotice := m.ExternalError if m.ErrorNotice != "" { errorNotice = m.ErrorNotice } notice := m.ExternalNotice if m.Notice != "" { notice = m.Notice } if errorNotice != "" { rows = append(rows, m.Renderer.errorRow(errorNotice), "") } else if notice != "" { rows = append(rows, m.Styles.Faint.Render(notice), "") }
view := m.Viewport.View() if m.History.Len() == 0 && m.activeText == "" && m.ToolProgressText == "" { view = m.Renderer.rail(laneNone) + m.Styles.Faint.Render("what are we working on?") } 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 { content = HighlightSelection(content, Selection{Anchor: *m.dragAnchor, Head: m.dragHead, 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.ErrorNotice == "" && m.ExternalError == "" { status = m.Styles.Faint.Render(status) + " " + m.Styles.Agent.Render(m.phaseMood().frame(m.moodSeed, m.ProgressFrame)) } if !m.Follow && m.Flags.Tools { 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 } if m.TurnFailed || m.ErrorNotice != "" || m.ExternalError != "" { rows = append(rows, m.Styles.Error.Render(status)) } else { rows = append(rows, m.Styles.Faint.Render(status)) } rows = append(rows, m.Styles.Decor.Render(strings.Repeat("─", width))) if m.WorkspaceRecovery != nil { rows = append(rows, m.Styles.Warning.Render("workspace not found: "+m.WorkspaceRecovery.Missing), m.RecoveryInput.View()) help := "enter confirms and retries · esc cancels" if m.WorkspaceRecovery.Saving { help = "updating workspace…" } if m.WorkspaceRecovery.Error != "" { help = m.WorkspaceRecovery.Error } if m.WorkspaceRecovery.Error != "" { rows = append(rows, m.Styles.Error.Render(help)) } else { rows = append(rows, m.Styles.Faint.Render(help)) } } else { rows = append(rows, m.composerView()) if menu := m.CommandMenu.View(m.TextArea.Value()); menu != "" { rows = append(rows, strings.Split(strings.TrimSuffix(menu, "\n"), "\n")...) } } rows = append(rows, m.renderFooter()) for i, row := range rows { rows[i] = pad + row } return strings.Join(rows, "\n")}
func (m ChatModel) composerView() string { value := m.TextArea.Value() lines := strings.Split(value, "\n") cursor := 0 for i := 0; i < min(m.TextArea.Line(), len(lines)); i++ { cursor += len([]rune(lines[i])) + 1 } cursor += m.TextArea.LineInfo().StartColumn + m.TextArea.LineInfo().ColumnOffset shown := []rune(strings.ReplaceAll(value, "\n", " ")) cursor = min(cursor, len(shown)) columns := max(1, m.chatWidth()-2) first := max(0, cursor-columns+1) end := min(len(shown), first+columns) before := string(shown[first:cursor]) block := " " if cursor < len(shown) { block = string(shown[cursor : cursor+1]) } after := "" if cursor+1 < end { after = string(shown[cursor+1 : end]) } if value == "" && m.waitingForInput() { after = m.Styles.Faint.Render(ansi.Truncate("waiting for user input", max(0, columns-1), "…")) } return promptLead() + before + m.Styles.Cursor.Render(block) + after}
// 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.Status.Phase != nil && !m.animating() && m.WorkspaceRecovery == nil}
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 = "✓" } rows = append(rows, style.Render(mark)+" "+item.Text) } 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() modes := []hint{{"thinking", onOff(m.Flags.Thinking)}, {"verbose", onOff(m.Flags.Tools)}} brief := []hint{{"t", onOff(m.Flags.Thinking)}, {"v", onOff(m.Flags.Tools)}} commands := hint{"/", "commands"} join := func(h ...[]hint) []hint { return slices.Concat(h...) } candidates := []struct { left []hint right string }{ {join([]hint{commands, {"shift+↑↓", "turns"}, {"drag", "copy"}, {"ctrl+j", "diffs"}}, modes), right}, {join([]hint{commands, {"drag", "copy"}, {"ctrl+j", "diffs"}}, modes), right}, {join([]hint{commands, {"drag", "copy"}}, modes), right}, {join([]hint{commands, {"ctrl+j", "diffs"}}, brief), compact}, {join([]hint{commands}, brief), compact}, {[]hint{{"/", ""}}, 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 cached 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(usage.CachedPromptTokens) + "/" + 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}
// 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" }}
func onOff(enabled bool) string { if enabled { return "on" } return "off"}