Files
coder/cli/exp_agents_model.go
T
Michael Suchacz 7904bed947 fix: fall back to local git watcher for chat diff drawer (#24512)
The Ctrl+D diff drawer in `coder exp agents` only rendered PR-backed
diffs returned by `/api/experimental/chats/{id}/diff`. Local working
tree changes in a chat's workspace returned an empty diff, so the
drawer showed "No diff contents" with no file summary.

Centralise diff loading behind a single `fetchChatDiffContents` helper
that first hits `/diff`, then falls back to the chat git watcher
WebSocket (`/stream/git`) when the remote diff is empty. Aggregate the
agent's `WorkspaceAgentRepoChanges` into a `ChatDiffContents` value so
the drawer can derive the file summary and styled body from the local
unified diff. Missing workspaces, missing agents, and watcher timeouts
are treated as graceful fallbacks that render the empty-diff
placeholder instead of a hard error.

> Mux is opening this PR on Mike's behalf.
2026-04-22 18:08:02 +02:00

515 lines
14 KiB
Go

package cli
import (
"context"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
type tuiView int
const (
viewList tuiView = iota
viewChat
)
type tuiOverlay int
const (
overlayNone tuiOverlay = iota
overlayModelPicker
overlayDiffDrawer
overlayAskUserQuestion
)
type (
terminateTUIMsg struct{}
expChatsTUIModel struct {
ctx context.Context
client *codersdk.ExperimentalClient
styles tuiStyles
currentView tuiView
overlay tuiOverlay
list chatListModel
chat chatViewModel
initialChatID *uuid.UUID
workspaceID *uuid.UUID
modelOverride *string
organizationID uuid.UUID
chatGeneration uint64
catalog *codersdk.ChatModelsResponse
quitting bool
width int
height int
}
)
func newExpChatsTUIModel(
ctx context.Context,
client *codersdk.ExperimentalClient,
initialChatID *uuid.UUID,
workspaceID *uuid.UUID,
modelOverride *string,
organizationID uuid.UUID,
) expChatsTUIModel {
styles := newTUIStyles()
currentView := viewList
if initialChatID != nil {
currentView = viewChat
}
chat := newChatViewModel(ctx, client, workspaceID, modelOverride, organizationID, styles)
chatGeneration := uint64(0)
if initialChatID != nil {
chat.activeChatID = *initialChatID
chat.chatGeneration = 1
chat.loading = true
chat.metadataResolved = false
chat.historyResolved = false
chatGeneration = 1
}
return expChatsTUIModel{
ctx: ctx,
client: client,
styles: styles,
currentView: currentView,
overlay: overlayNone,
list: newChatListModel(styles),
chat: chat,
initialChatID: initialChatID,
workspaceID: workspaceID,
modelOverride: modelOverride,
organizationID: organizationID,
chatGeneration: chatGeneration,
}
}
// resetChatSession creates a fresh chatViewModel, preserves the
// window dimensions from the previous session, and advances
// the monotonic generation counter so in-flight async messages
// from the old session are ignored.
func (m *expChatsTUIModel) resetChatSession() {
old := m.chat
m.chat = newChatViewModel(m.ctx, m.client, m.workspaceID, m.modelOverride, m.organizationID, m.styles)
m.chat.width = old.width
m.chat.height = old.height
m.chat.loading = true
m.chat.metadataResolved = false
m.chat.historyResolved = false
m.chatGeneration++
m.chat.chatGeneration = m.chatGeneration
}
func (m *expChatsTUIModel) setRenderer(renderer *lipgloss.Renderer) {
styles := newTUIStyles(renderer)
m.styles = styles
m.list.styles = styles
m.list.spinner.Style = styles.dimmedText
m.chat.styles = styles
m.chat.spinner.Style = styles.dimmedText
}
func (m expChatsTUIModel) Init() tea.Cmd {
if m.initialChatID != nil {
m.chat.activeChatID = *m.initialChatID
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*m.initialChatID, m.chat.chatGeneration)...)...)
}
return tea.Batch(m.loadChatsCmd(), m.list.Init())
}
func (m expChatsTUIModel) loadChatsCmd() tea.Cmd {
return apiCmd(func() ([]codersdk.Chat, error) { return m.client.ListChats(m.ctx, nil) }, func(chats []codersdk.Chat, err error) tea.Msg { return chatsListedMsg{chats: chats, err: err} })
}
func (m expChatsTUIModel) loadChatCmd(chatID uuid.UUID, generation uint64) []tea.Cmd {
return []tea.Cmd{apiCmd(func() (codersdk.Chat, error) { return m.client.GetChat(m.ctx, chatID) }, func(chat codersdk.Chat, err error) tea.Msg {
return chatOpenedMsg{generation: generation, chatID: chatID, chat: chat, err: err}
}), loadChatHistoryCmd(m.ctx, m.client, chatID, generation)}
}
func (m expChatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
h := m.height
if m.currentView == viewList {
h = max(0, h-1)
}
return tea.WindowSizeMsg{Width: m.width, Height: h}
}
func (m *expChatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
if m.overlay == overlay {
m.overlay = overlayNone
return false
}
m.overlay = overlay
return true
}
func (m *expChatsTUIModel) handleEsc(msg tea.KeyMsg) tea.Cmd {
if m.currentView == viewList && m.list.searching {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return cmd
}
if m.currentView == viewChat {
m.chatGeneration++
m.chat.chatGeneration = m.chatGeneration
m.chat.stopStream()
m.currentView = viewList
m.list.loading = true
return m.loadChatsCmd()
}
m.quitting = true
return tea.Quit
}
func isOverlayCloseKey(msg tea.KeyMsg) bool {
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEscape {
return true
}
key := msg.String()
return key == "esc" || key == "ctrl+["
}
func (m *expChatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "up", "k":
if m.chat.modelPickerCursor > 0 {
m.chat.modelPickerCursor--
}
case "down", "j":
if m.chat.modelPickerCursor < len(m.chat.modelPickerFlat)-1 {
m.chat.modelPickerCursor++
}
case "enter":
if len(m.chat.modelPickerFlat) > 0 && m.chat.modelPickerCursor < len(m.chat.modelPickerFlat) {
selected := m.chat.modelPickerFlat[m.chat.modelPickerCursor]
m.chat.modelOverride = &selected.ID
m.modelOverride = &selected.ID
m.overlay = overlayNone
}
case "ctrl+p", "q":
m.overlay = overlayNone
}
return nil
}
func (m *expChatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || state.Submitting || len(state.Questions) == 0 {
return nil
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return nil
}
if state.OtherMode {
switch msg.Type {
case tea.KeyEsc:
state.OtherMode = false
state.OtherInput.Blur()
return nil
case tea.KeyEnter:
answer := strings.TrimSpace(state.OtherInput.Value())
if answer == "" {
return nil
}
return m.recordAskAnswer(answer, "", true)
default:
var cmd tea.Cmd
state.OtherInput, cmd = state.OtherInput.Update(msg)
return cmd
}
}
question := state.Questions[state.CurrentIndex]
optionCount := len(question.Options) + 1
switch msg.String() {
case "up", "k":
state.OptionCursor--
if state.OptionCursor < 0 {
state.OptionCursor = optionCount - 1
}
case "down", "j":
state.OptionCursor++
if state.OptionCursor >= optionCount {
state.OptionCursor = 0
}
case "left", "h":
if state.CurrentIndex == 0 {
return nil
}
state.CurrentIndex--
state.OptionCursor = 0
state.OtherMode = false
state.OtherInput.Blur()
state.Error = nil
if len(state.Answers) > state.CurrentIndex {
state.Answers = state.Answers[:state.CurrentIndex]
}
case "enter":
state.Error = nil
if state.OptionCursor < len(question.Options) {
option := question.Options[state.OptionCursor]
answer := strings.TrimSpace(option.Value)
if answer == "" {
answer = option.Label
}
return m.recordAskAnswer(answer, option.Label, false)
}
state.OtherMode = true
state.OtherInput.SetValue("")
state.OtherInput.Focus()
}
return nil
}
func (m *expChatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform bool) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || len(state.Questions) == 0 {
return nil
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return nil
}
question := state.Questions[state.CurrentIndex]
if len(state.Answers) > state.CurrentIndex {
state.Answers = state.Answers[:state.CurrentIndex]
}
state.Answers = append(state.Answers, askQuestionAnswer{
Header: question.Header,
Question: question.Question,
Answer: answer,
OptionLabel: optionLabel,
Freeform: freeform,
})
state.OtherMode = false
state.OtherInput.Blur()
state.OtherInput.SetValue("")
state.OptionCursor = 0
state.Error = nil
if state.CurrentIndex+1 < len(state.Questions) {
state.CurrentIndex++
return nil
}
state.Submitting = true
return submitAskUserQuestionCmd(m.client.Client, m.chat.activeChatID, m.chat.chatGeneration, state)
}
func (m *expChatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd {
m.currentView = viewChat
m.chat.stopStream()
m.resetChatSession()
if chatID == nil {
m.chat.draft = true
m.chat.loading = false
m.chat.metadataResolved = true
m.chat.historyResolved = true
m.chat, _ = m.chat.Update(m.childWindowSizeMsg())
return nil
}
m.chat.activeChatID = *chatID
m.chat, _ = m.chat.Update(m.childWindowSizeMsg())
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*chatID, m.chat.chatGeneration)...)...)
}
func (m *expChatsTUIModel) toggleModelPickerCmd() tea.Cmd {
if !m.toggleOverlay(overlayModelPicker) {
return nil
}
if m.catalog == nil {
return apiCmd(func() (codersdk.ChatModelsResponse, error) { return m.client.ListChatModels(m.ctx) }, func(catalog codersdk.ChatModelsResponse, err error) tea.Msg {
return modelsListedMsg{catalog: catalog, err: err}
})
}
if len(m.chat.modelPickerFlat) == 0 {
m.chat.modelPickerFlat = availableChatModels(*m.catalog)
}
return nil
}
func (m *expChatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
if m.chat.chat == nil {
return nil
}
if !m.toggleOverlay(overlayDiffDrawer) {
return nil
}
if m.chat.diffContents == nil || m.chat.diffErr != nil {
m.chat.diffErr = nil
chatID := m.chat.chat.ID
generation := m.chat.chatGeneration
return apiCmd(func() (codersdk.ChatDiffContents, error) { return fetchChatDiffContents(m.ctx, m.client, chatID) }, func(diff codersdk.ChatDiffContents, err error) tea.Msg {
return diffContentsMsg{generation: generation, chatID: chatID, diff: diff, err: err}
})
}
return nil
}
func (m expChatsTUIModel) updateChild(msg tea.Msg, view tuiView) (expChatsTUIModel, tea.Cmd) {
var cmd tea.Cmd
if view == viewChat {
m.chat, cmd = m.chat.Update(msg)
} else {
m.list, cmd = m.list.Update(msg)
}
return m, cmd
}
func (m expChatsTUIModel) renderOverlay(title, body string) string {
return renderOverlayFrame(m.styles, m.width, m.styles.title.Render(title), body, m.styles.helpText.Render("Esc to close"))
}
func (m expChatsTUIModel) diffOverlayView() string {
switch {
case m.chat.diffErr != nil:
return m.renderOverlay("Diff", m.styles.errorText.Render(wrapPreservingNewlines(m.chat.diffErr.Error(), contentWidth(m.width, 6))))
case m.chat.diffContents != nil:
return renderDiffDrawer(m.styles, *m.chat.diffContents, m.chat.diffSummary, m.chat.diffStyledBody, m.width, m.height)
default:
return m.renderOverlay("Diff", m.styles.dimmedText.Render("Loading diff…"))
}
}
func padViewHeight(text string, height int) string {
if height <= 0 {
return text
}
if text == "" {
return strings.Repeat("\n", max(height-1, 0))
}
lineCount := countRenderedLines(text)
if lineCount >= height {
return text
}
return text + strings.Repeat("\n", height-lineCount)
}
func (m expChatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
childMsg := m.childWindowSizeMsg()
m.list, _ = m.list.Update(childMsg)
m.chat, _ = m.chat.Update(childMsg)
return m, nil
case terminateTUIMsg:
m.quitting = true
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
// Handle overlays first so their keys do not leak to the underlying
// view.
if m.overlay == overlayAskUserQuestion {
return m, m.handleAskUserQuestionKey(msg)
}
if m.overlay == overlayModelPicker {
if isOverlayCloseKey(msg) {
m.overlay = overlayNone
return m, tea.ClearScreen
}
cmd := m.handleModelPickerKey(msg)
if m.overlay == overlayNone {
return m, tea.Batch(cmd, tea.ClearScreen)
}
return m, cmd
}
if m.overlay == overlayDiffDrawer {
if isOverlayCloseKey(msg) {
m.overlay = overlayNone
return m, tea.ClearScreen
}
return m, nil
}
if msg.String() == "esc" {
return m, m.handleEsc(msg)
}
case openSelectedChatMsg:
return m, m.openChatCmd(&msg.chatID)
case openDraftChatMsg:
return m, m.openChatCmd(nil)
case refreshChatsMsg:
return m, m.loadChatsCmd()
case toggleModelPickerMsg:
return m, m.toggleModelPickerCmd()
case toggleDiffDrawerMsg:
return m, m.toggleDiffDrawerCmd()
case showAskUserQuestionMsg:
m.chat.pendingAskUserQuestion = msg.state
m.overlay = overlayAskUserQuestion
return m.updateChild(msg, viewChat)
case hideAskUserQuestionMsg:
if m.overlay == overlayAskUserQuestion {
m.overlay = overlayNone
}
return m.updateChild(msg, viewChat)
case toolResultsSubmittedMsg:
if msg.err == nil && m.chat.matchesGeneration(msg.generation) && msg.chatID == m.chat.activeChatID {
m.chat.pendingAskUserQuestion = nil
if m.overlay == overlayAskUserQuestion {
m.overlay = overlayNone
}
}
return m.updateChild(msg, viewChat)
case chatsListedMsg:
return m.updateChild(msg, viewList)
case chatOpenedMsg, chatHistoryMsg, chatStreamEventMsg, messageSentMsg, chatCreatedMsg, chatInterruptedMsg, diffContentsMsg:
return m.updateChild(msg, viewChat)
case modelsListedMsg:
if msg.err != nil {
m.overlay = overlayNone
} else {
catalog := msg.catalog
m.catalog = &catalog
}
return m.updateChild(msg, viewChat)
}
return m.updateChild(msg, m.currentView)
}
func (m expChatsTUIModel) View() string {
if m.quitting {
return ""
}
var base string
if m.currentView == viewChat {
base = m.chat.View()
} else {
base = m.styles.title.Render("Coder Chats") + "\n" + m.list.View()
}
switch m.overlay {
case overlayAskUserQuestion:
if m.chat.pendingAskUserQuestion != nil {
base += "\n" + renderAskUserQuestion(m.styles, m.chat.pendingAskUserQuestion, m.width, m.height)
}
case overlayModelPicker:
if m.catalog == nil {
base += "\n" + m.renderOverlay("Select Model", m.styles.dimmedText.Render("Loading models..."))
break
}
selectedID := ""
if m.chat.modelOverride != nil {
selectedID = *m.chat.modelOverride
}
base += "\n" + renderModelPicker(m.styles, *m.catalog, selectedID, m.chat.modelPickerCursor, m.width, m.height)
case overlayDiffDrawer:
base += "\n" + m.diffOverlayView()
}
return padViewHeight(base, m.height)
}