diff --git a/cli/exp_agents.go b/cli/exp_agents.go new file mode 100644 index 0000000000..7181911015 --- /dev/null +++ b/cli/exp_agents.go @@ -0,0 +1,207 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + "github.com/muesli/termenv" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func installTUISignalHandler(p *tea.Program) func() { + ch := make(chan struct{}) + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + defer func() { + signal.Stop(sig) + close(ch) + }() + for { + select { + case <-ch: + return + case <-sig: + p.Send(terminateTUIMsg{}) + } + } + }() + return func() { + ch <- struct{}{} + } +} + +func fitHelpText(width int, candidates ...string) string { + if len(candidates) == 0 { + return "" + } + if width <= 0 { + return candidates[0] + } + for _, candidate := range candidates { + if lipgloss.Width(candidate) <= width { + return candidate + } + } + return truncateText(candidates[len(candidates)-1], width, " •|│:", 1) +} + +func truncateText(text string, width int, trimRightCutset string, ellipsisWidth int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(text) <= width { + return text + } + if width <= ellipsisWidth { + return "…" + } + for runes := []rune(text); len(runes) > 0; runes = runes[:len(runes)-1] { + truncated := strings.TrimRight(string(runes), trimRightCutset) + "…" + if lipgloss.Width(truncated) <= width { + return truncated + } + } + return "…" +} + +func (r *RootCmd) agentsCommand() *serpent.Command { + var ( + workspaceFlag string + modelFlag string + ) + + return &serpent.Command{ + Use: "agents [chat-id]", + Short: "Interactive terminal UI for AI agents.", + Aliases: []string{"agent"}, + Options: serpent.OptionSet{ + { + Name: "workspace", + Flag: "workspace", + Description: "Associate the chat with a workspace by name, owner/name, or UUID.", + Value: serpent.StringOf(&workspaceFlag), + }, + { + Name: "model", + Flag: "model", + Description: "Choose a model by ID, provider/model, or display name.", + Value: serpent.StringOf(&modelFlag), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("list organizations: %w", err) + } + if len(orgs) == 0 { + return xerrors.New("no organizations found") + } + defaultOrgID := orgs[0].ID + + expClient := codersdk.NewExperimentalClient(client) + + if len(inv.Args) > 1 { + return xerrors.New("expected zero or one chat ID") + } + + var initialChatID *uuid.UUID + if len(inv.Args) == 1 { + chatID, err := uuid.Parse(inv.Args[0]) + if err != nil { + return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + } + initialChatID = &chatID + } + + var workspaceID *uuid.UUID + if workspaceFlag != "" { + workspace, err := namedWorkspace(inv.Context(), client, workspaceFlag) + if err != nil { + return xerrors.Errorf("resolve workspace %q: %w", workspaceFlag, err) + } + workspaceID = &workspace.ID + } + + modelID, err := resolveModel(inv.Context(), expClient, modelFlag) + if err != nil { + return err + } + + // Set an explicit color profile before Bubble Tea acquires the + // terminal so lipgloss/termenv don't send OSC color queries that + // can leak back into stdin as literal input in some terminals. + renderer := lipgloss.NewRenderer( + inv.Stdout, + termenv.WithProfile(termenv.TrueColor), + ) + renderer.SetHasDarkBackground(true) + + model := newExpChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID) + model.setRenderer(renderer) + program := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithoutSignalHandler(), + tea.WithContext(inv.Context()), + tea.WithInput(inv.Stdin), + tea.WithOutput(inv.Stdout), + ) + + closeSignalHandler := installTUISignalHandler(program) + defer closeSignalHandler() + + runModel, err := program.Run() + if err != nil { + return err + } + + if _, ok := runModel.(expChatsTUIModel); !ok { + return xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", runModel, runModel)) + } + + return nil + }, + } +} + +//nolint:nilnil // A nil string indicates that no model override was provided. +func resolveModel(ctx context.Context, client *codersdk.ExperimentalClient, modelFlag string) (*string, error) { + if modelFlag == "" { + return nil, nil + } + + if _, err := uuid.Parse(modelFlag); err == nil { + return &modelFlag, nil + } + + catalog, err := client.ListChatModels(ctx) + if err != nil { + return nil, xerrors.Errorf("listing models: %w", err) + } + + for _, provider := range catalog.Providers { + for _, model := range provider.Models { + if model.ID == modelFlag || model.Provider+"/"+model.Model == modelFlag || model.DisplayName == modelFlag { + return &model.ID, nil + } + } + } + + return nil, xerrors.Errorf("unknown model %q", modelFlag) +} diff --git a/cli/exp_agents_chat.go b/cli/exp_agents_chat.go new file mode 100644 index 0000000000..6b60cff1ed --- /dev/null +++ b/cli/exp_agents_chat.go @@ -0,0 +1,1436 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +type chatBlockKind int + +const ( + blockText chatBlockKind = iota + blockReasoning + blockToolCall + blockToolResult + blockCompaction +) + +type chatBlock struct { + kind chatBlockKind + role codersdk.ChatMessageRole + text string + toolName string + toolID string + args string + result string + isError bool + collapsedCount int + + cachedRender string + cachedWidth int + cachedExpanded bool + cachedCollapsedCount int +} + +type spinnerState bool + +type streamAccumulator struct { + parts []codersdk.ChatMessagePart + role codersdk.ChatMessageRole + pending bool + toolDeltas map[string]string +} + +func (a *streamAccumulator) applyDelta(mp codersdk.ChatStreamMessagePart) { + a.pending = true + a.role = mp.Role + part := mp.Part + + switch part.Type { + case codersdk.ChatMessagePartTypeText, codersdk.ChatMessagePartTypeReasoning: + if len(a.parts) > 0 && a.parts[len(a.parts)-1].Type == part.Type { + a.parts[len(a.parts)-1].Text += part.Text + } else { + a.parts = append(a.parts, part) + } + case codersdk.ChatMessagePartTypeToolCall: + if part.ArgsDelta != "" { + if a.toolDeltas == nil { + a.toolDeltas = make(map[string]string) + } + a.toolDeltas[part.ToolCallID] += part.ArgsDelta + found := false + for i, p := range a.parts { + if p.Type == codersdk.ChatMessagePartTypeToolCall && p.ToolCallID == part.ToolCallID { + a.parts[i].Args = json.RawMessage([]byte(a.toolDeltas[part.ToolCallID])) + found = true + break + } + } + if !found { + newPart := part + newPart.Args = json.RawMessage([]byte(a.toolDeltas[part.ToolCallID])) + newPart.ArgsDelta = "" + a.parts = append(a.parts, newPart) + } + } else { + found := false + for i, p := range a.parts { + if p.Type == codersdk.ChatMessagePartTypeToolCall && p.ToolCallID == part.ToolCallID { + a.parts[i] = part + found = true + break + } + } + if !found { + a.parts = append(a.parts, part) + } + } + default: + a.parts = append(a.parts, part) + } +} + +func (a streamAccumulator) isPending() bool { + return a.pending +} + +func (a *streamAccumulator) reset() { + *a = streamAccumulator{} +} + +// parsedAskOption represents one selectable option for a question. +type parsedAskOption struct { + Label string + Value string +} + +// parsedAskQuestion represents a single question within an ask_user_question +// tool call. +type parsedAskQuestion struct { + Header string + Question string + Options []parsedAskOption +} + +// askQuestionAnswer holds the user's answer for one question. +type askQuestionAnswer struct { + Header string `json:"header"` + Question string `json:"question"` + Answer string `json:"answer"` + OptionLabel string `json:"option_label,omitempty"` + Freeform bool `json:"freeform"` +} + +// askUserQuestionState holds the full state for an active ask_user_question +// overlay. +type askUserQuestionState struct { + ToolCallID string + Questions []parsedAskQuestion + Answers []askQuestionAnswer + CurrentIndex int + OptionCursor int + OtherMode bool + OtherInput textinput.Model + Submitting bool + Error error +} + +type askUserQuestionArgs struct { + Questions []parsedAskQuestion `json:"questions"` +} + +func newAskUserQuestionState(toolCallID string, questions []parsedAskQuestion) *askUserQuestionState { + otherInput := textinput.New() + otherInput.Placeholder = "Type your answer..." + + return &askUserQuestionState{ + ToolCallID: toolCallID, + Questions: questions, + Answers: make([]askQuestionAnswer, 0, len(questions)), + OtherInput: otherInput, + } +} + +func parseAskUserQuestionArgs(toolCallID string, rawArgs json.RawMessage) (*askUserQuestionState, error) { + var args askUserQuestionArgs + if err := json.Unmarshal(rawArgs, &args); err != nil { + return nil, xerrors.Errorf("parse ask_user_question args: %w", err) + } + if len(args.Questions) == 0 { + return nil, xerrors.New("ask_user_question args must include at least one question") + } + + return newAskUserQuestionState(toolCallID, args.Questions), nil +} + +func parseAskUserQuestionToolCall(toolCall codersdk.ChatStreamToolCall) (*askUserQuestionState, error) { + return parseAskUserQuestionArgs(toolCall.ToolCallID, json.RawMessage([]byte(toolCall.Args))) +} + +func buildAskUserQuestionToolResult(state *askUserQuestionState) (json.RawMessage, error) { + if state == nil { + return nil, xerrors.New("ask-user-question state is required") + } + + answers := state.Answers + if answers == nil { + answers = []askQuestionAnswer{} + } + + output, err := json.Marshal(struct { + Answers []askQuestionAnswer `json:"answers"` + }{ + Answers: answers, + }) + if err != nil { + return nil, xerrors.Errorf("marshal ask_user_question tool result: %w", err) + } + return json.RawMessage(output), nil +} + +func findPendingAskUserQuestion(messages []codersdk.ChatMessage) (*askUserQuestionState, error) { + answeredToolCalls := make(map[string]struct{}) + for i := len(messages) - 1; i >= 0; i-- { + for j := len(messages[i].Content) - 1; j >= 0; j-- { + part := messages[i].Content[j] + if part.Type != codersdk.ChatMessagePartTypeToolResult || part.ToolCallID == "" { + continue + } + if !toolResultHasAnswers(part.Result) { + continue + } + answeredToolCalls[part.ToolCallID] = struct{}{} + } + } + + for i := len(messages) - 1; i >= 0; i-- { + for j := len(messages[i].Content) - 1; j >= 0; j-- { + part := messages[i].Content[j] + if part.Type != codersdk.ChatMessagePartTypeToolCall || part.ToolName != "ask_user_question" { + continue + } + if _, ok := answeredToolCalls[part.ToolCallID]; ok { + continue + } + return parseAskUserQuestionArgs(part.ToolCallID, part.Args) + } + } + + //nolint:nilnil // Nil state and nil error mean no pending tool call was found. + return nil, nil +} + +// toolResultHasAnswers returns true when the tool result payload contains an +// "answers" field, which indicates the user submitted answers for an +// ask_user_question tool call. +func toolResultHasAnswers(result json.RawMessage) bool { + if len(result) == 0 { + return false + } + + var shape struct { + Answers json.RawMessage `json:"answers"` + } + if err := json.Unmarshal(result, &shape); err != nil { + return false + } + return len(shape.Answers) > 0 +} + +type chatViewModel struct { + styles tuiStyles + chat *codersdk.Chat + messages []codersdk.ChatMessage + blocks []chatBlock + loading bool + err error + metadataResolved bool + historyResolved bool + metadataErr error + historyErr error + draft bool + composer textinput.Model + viewport viewport.Model + spinner spinner.Model + accumulator streamAccumulator + width int + height int + cachedRenderer *glamour.TermRenderer + cachedRendererWidth int + lastTranscript string + + ctx context.Context + client *codersdk.ExperimentalClient + workspaceID *uuid.UUID + modelOverride *string + organizationID uuid.UUID + activeChatID uuid.UUID + chatGeneration uint64 + intentionalClose bool + creatingChat bool + pendingComposerText string + planMode codersdk.ChatPlanMode + + streaming bool + streamCloser io.Closer + streamEventCh <-chan codersdk.ChatStreamEvent + reconnecting bool + + chatStatus codersdk.ChatStatus + lastUsage *codersdk.ChatMessageUsage + queuedMessages []codersdk.ChatQueuedMessage + pendingAskUserQuestion *askUserQuestionState + + composerFocused bool + selectedBlock int + expandedBlocks map[int]bool + autoFollow bool + interrupting bool + + diffStatus *codersdk.ChatDiffStatus + gitChanges []codersdk.ChatGitChange + diffContents *codersdk.ChatDiffContents + diffErr error + + modelPickerFlat []codersdk.ChatModel + modelPickerCursor int +} + +func modelOverrideUUID(modelOverride *string) *uuid.UUID { + if modelOverride == nil { + return nil + } + + modelConfigID, err := uuid.Parse(*modelOverride) + if err != nil { + return nil + } + return &modelConfigID +} + +func canonicalChatModelID(provider, model string) string { + return strings.ToLower(strings.TrimSpace(provider)) + ":" + strings.TrimSpace(model) +} + +func normalizeChatModelOverride(modelOverride string) string { + modelOverride = strings.TrimSpace(modelOverride) + provider, model, ok := strings.Cut(modelOverride, "/") + if ok { + return canonicalChatModelID(provider, model) + } + provider, model, ok = strings.Cut(modelOverride, ":") + if ok { + return canonicalChatModelID(provider, model) + } + return modelOverride +} + +func resolveModelConfigID(ctx context.Context, client *codersdk.ExperimentalClient, modelOverride *string) (*uuid.UUID, error) { + if modelOverride == nil { + return nil, xerrors.New("model override is required") + } + if modelConfigID := modelOverrideUUID(modelOverride); modelConfigID != nil { + return modelConfigID, nil + } + + configs, err := client.ListChatModelConfigs(ctx) + if err != nil { + return nil, xerrors.Errorf("list chat model configs: %w", err) + } + + canonicalOverride := normalizeChatModelOverride(*modelOverride) + for _, config := range configs { + if canonicalChatModelID(config.Provider, config.Model) != canonicalOverride { + continue + } + modelConfigID := config.ID + return &modelConfigID, nil + } + + return nil, xerrors.Errorf("resolve model config ID for %q: no matching enabled model config", *modelOverride) +} + +func newChatViewModel( + ctx context.Context, + client *codersdk.ExperimentalClient, + workspaceID *uuid.UUID, + modelOverride *string, + organizationID uuid.UUID, + styles tuiStyles, +) chatViewModel { + composer := textinput.New() + composer.Placeholder = "Type a message..." + composer.Prompt = "> " + composer.Focus() + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styles.dimmedText + + model := chatViewModel{ + ctx: ctx, + client: client, + workspaceID: workspaceID, + modelOverride: modelOverride, + organizationID: organizationID, + styles: styles, + loading: false, + metadataResolved: true, + historyResolved: true, + composerFocused: true, + expandedBlocks: make(map[int]bool), + autoFollow: true, + composer: composer, + viewport: viewport.New(0, 0), + spinner: s, + } + model.setComposerWidth() + return model +} + +func (m *chatViewModel) setComposerWidth() { + m.composer.Width = max(10, m.width-4) +} + +func (m *chatViewModel) recalcViewportHeight() { + if m.height <= 0 || m.width <= 0 { + return + } + + viewWidth := m.width + if viewWidth <= 0 { + viewWidth = 80 + } + + composerView := m.styles.composerStyle.Width(max(10, viewWidth-2)).Render(m.composer.View()) + composerHeight := lipgloss.Height(composerView) + + const nonViewportHeight = 4 + m.viewport.Width = m.width + m.viewport.Height = max(0, m.height-nonViewportHeight-composerHeight) +} +func (m *chatViewModel) refreshViewport() { m.recalcViewportHeight(); m.syncViewportContent() } + +func (m chatViewModel) readyToStartStream() bool { + return m.metadataResolved && m.historyResolved && m.err == nil && m.chat != nil && m.client != nil && !m.streaming +} + +func (m *chatViewModel) finishLoading(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { + m.err = m.historyErr + if m.metadataErr != nil { + m.err = m.metadataErr + } + m.loading = !m.metadataResolved || !m.historyResolved + return m.startStreamIfReady(wasSpinnerActive) +} + +// restorePendingComposerIfEmpty restores pending text to the +// composer only when the user has not typed new input since the +// original send was dispatched. +func (m *chatViewModel) restorePendingComposerIfEmpty() { + if m.pendingComposerText != "" && m.composer.Value() == "" { + m.composer.SetValue(m.pendingComposerText) + m.recalcViewportHeight() + } +} + +func (m *chatViewModel) stopStream() { + m.intentionalClose = true + if m.streamCloser != nil { + _ = m.streamCloser.Close() + m.streaming, m.streamCloser, m.streamEventCh = false, nil, nil + } +} + +// matchesGeneration returns true when the generation embedded in an +// async message matches the current chat session generation. This +// prevents stale results from previous sessions (including drafts) +// from mutating the active view. +func (m chatViewModel) matchesGeneration(gen uint64) bool { + return m.chatGeneration == gen +} + +func (m *chatViewModel) setChat(chat codersdk.Chat) { + m.chat = &chat + m.activeChatID = chat.ID + m.chatStatus = chat.Status + m.diffStatus = chat.DiffStatus + m.gitChanges = nil + m.diffContents = nil + m.diffErr = nil +} + +// recoverPendingAskUserQuestion restores the pending ask_user_question +// overlay after reopening a chat that is waiting on client input. +func (m *chatViewModel) recoverPendingAskUserQuestion() (tea.Cmd, error) { + if m.chatStatus != codersdk.ChatStatusRequiresAction { + return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. + } + + state, err := findPendingAskUserQuestion(m.messages) + if err != nil { + return nil, xerrors.Errorf("recover pending ask_user_question: %w", err) + } + return m.showPendingAskUserQuestion(state), nil +} + +func (m *chatViewModel) recoverPendingAskUserQuestionFromAccumulator() (tea.Cmd, error) { + if m.chatStatus != codersdk.ChatStatusRequiresAction { + return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. + } + + for i := len(m.accumulator.parts) - 1; i >= 0; i-- { + part := m.accumulator.parts[i] + if part.Type != codersdk.ChatMessagePartTypeToolCall || + part.ToolName != "ask_user_question" { + continue + } + + state, err := parseAskUserQuestionArgs(part.ToolCallID, part.Args) + if err != nil { + return nil, xerrors.Errorf( + "recover pending ask_user_question from accumulator: %w", + err, + ) + } + return m.showPendingAskUserQuestion(state), nil + } + + return nil, nil //nolint:nilnil // Nil command means there is no pending recovery work. +} + +func (m *chatViewModel) showPendingAskUserQuestion(state *askUserQuestionState) tea.Cmd { + if state == nil { + return nil + } + if m.pendingAskUserQuestion != nil && + m.pendingAskUserQuestion.ToolCallID == state.ToolCallID { + return nil + } + + m.pendingAskUserQuestion = state + return func() tea.Msg { + return showAskUserQuestionMsg{state: state} + } +} + +func (m chatViewModel) isInterruptible() bool { + return m.chatStatus == codersdk.ChatStatusPending || + m.chatStatus == codersdk.ChatStatusRunning +} + +func (m chatViewModel) shouldReconnect() bool { + return m.chat != nil && (m.isInterruptible() || m.chatStatus == codersdk.ChatStatusWaiting) +} +func (m chatViewModel) Init() tea.Cmd { return m.spinner.Tick } +func (m chatViewModel) spinnerActive() bool { + return m.reconnecting || m.accumulator.pending || m.isInterruptible() +} + +func (m chatViewModel) spinnerLabel() string { + if m.reconnecting { + return "Reconnecting..." + } + return "Thinking..." +} + +// spinnerVisibleInViewport reports whether the transient spinner line is +// currently visible. When it is offscreen we can skip spinner-only transcript +// refreshes and avoid scroll artifacts while preserving the next visible frame. +func (m chatViewModel) spinnerVisibleInViewport() bool { + return m.viewport.AtBottom() +} + +func (m chatViewModel) startSpinnerIfNeeded(wasSpinnerActive spinnerState, cmd tea.Cmd) tea.Cmd { + if bool(wasSpinnerActive) || !m.spinnerActive() { + return cmd + } + if cmd == nil { + return m.spinner.Tick + } + return tea.Batch(cmd, m.spinner.Tick) +} + +func availableChatModels(catalog codersdk.ChatModelsResponse) []codersdk.ChatModel { + var models []codersdk.ChatModel + for _, provider := range catalog.Providers { + if provider.Available { + models = append(models, provider.Models...) + } + } + return models +} + +func (m chatViewModel) togglePlanMode() codersdk.ChatPlanMode { + if m.planMode == codersdk.ChatPlanModePlan { + return "" + } + return codersdk.ChatPlanModePlan +} + +func (m chatViewModel) updatePlanModeCmd() tea.Cmd { + mode := m.planMode + return apiCmd(func() (struct{}, error) { + return struct{}{}, m.client.UpdateChat(m.ctx, m.chat.ID, codersdk.UpdateChatRequest{ + PlanMode: &mode, + }) + }, func(_ struct{}, err error) tea.Msg { + return chatPlanModeUpdatedMsg{generation: m.chatGeneration, chatID: m.chat.ID, err: err} + }) +} + +// sendMessage trims the composer, builds the content, and dispatches +// a create-chat or send-message command. +func (m chatViewModel) sendMessage() (chatViewModel, tea.Cmd) { + text := strings.TrimSpace(m.composer.Value()) + if text == "" { + return m, nil + } + if m.loading { + return m, nil + } + if !m.draft && m.chat == nil { + return m, nil + } + if m.draft && m.creatingChat { + return m, nil + } + m.autoFollow = true + m.pendingComposerText = text + m.composer.SetValue("") + (&m).recalcViewportHeight() + content := []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: text, + }} + + modelConfigID := modelOverrideUUID(m.modelOverride) + + if m.draft { + req := codersdk.CreateChatRequest{ + OrganizationID: m.organizationID, + Content: content, + WorkspaceID: m.workspaceID, + ModelConfigID: modelConfigID, + PlanMode: m.planMode, + } + m.creatingChat = true + return m, apiCmd(func() (codersdk.Chat, error) { + if req.ModelConfigID == nil && m.modelOverride != nil { + modelConfigID, err := resolveModelConfigID(m.ctx, m.client, m.modelOverride) + if err != nil { + return codersdk.Chat{}, err + } + req.ModelConfigID = modelConfigID + } + return m.client.CreateChat(m.ctx, req) + }, func(chat codersdk.Chat, err error) tea.Msg { + return chatCreatedMsg{generation: m.chatGeneration, chatID: chat.ID, chat: chat, err: err} + }) + } + + mode := m.planMode + req := codersdk.CreateChatMessageRequest{ + Content: content, + ModelConfigID: modelConfigID, + PlanMode: &mode, + } + return m, apiCmd(func() (codersdk.CreateChatMessageResponse, error) { + if req.ModelConfigID == nil && m.modelOverride != nil { + modelConfigID, err := resolveModelConfigID(m.ctx, m.client, m.modelOverride) + if err != nil { + return codersdk.CreateChatMessageResponse{}, err + } + req.ModelConfigID = modelConfigID + } + return m.client.CreateChatMessage(m.ctx, m.chat.ID, req) + }, func(resp codersdk.CreateChatMessageResponse, err error) tea.Msg { + return messageSentMsg{generation: m.chatGeneration, chatID: m.chat.ID, resp: resp, err: err} + }) +} + +// startStream opens a streaming connection from the latest known message ID. +func (m chatViewModel) startStream() (chatViewModel, tea.Cmd) { + if m.chat == nil || m.streaming { + return m, nil + } + m.intentionalClose = false + + var opts *codersdk.StreamChatOptions + if len(m.messages) > 0 { + lastID := m.messages[len(m.messages)-1].ID + opts = &codersdk.StreamChatOptions{AfterID: &lastID} + } + + eventCh, closer, err := m.client.StreamChat(m.ctx, m.chat.ID, opts) + if err != nil { + m.err = err + return m, nil + } + m.streaming, m.streamCloser, m.streamEventCh, m.reconnecting = true, closer, eventCh, false + m.syncViewportContent() + return m, listenToStream(m.activeChatID, m.chatGeneration, m.streamEventCh) +} + +func (m chatViewModel) startStreamWithSpinner(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { + updated, cmd := m.startStream() + return updated, updated.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), cmd) +} + +func (m chatViewModel) startStreamIfReady(wasSpinnerActive bool) (chatViewModel, tea.Cmd) { + if !m.readyToStartStream() { + return m, m.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), nil) + } + return m.startStreamWithSpinner(wasSpinnerActive) +} + +// rebuildBlocks merges persisted messages + accumulator into renderable blocks. +func (m *chatViewModel) rebuildBlocks() { + oldBlocks := m.blocks + m.blocks = messagesToBlocks(m.messages) + + if m.accumulator.pending { + finalizedToolIDs := make(map[string]struct{}, len(m.blocks)) + for _, block := range m.blocks { + if block.toolID == "" { + continue + } + finalizedToolIDs[block.toolID] = struct{}{} + } + for _, part := range m.accumulator.parts { + if (part.Type == codersdk.ChatMessagePartTypeToolCall || part.Type == codersdk.ChatMessagePartTypeToolResult) && part.ToolCallID != "" { + if _, ok := finalizedToolIDs[part.ToolCallID]; ok { + continue + } + } + switch part.Type { + case codersdk.ChatMessagePartTypeReasoning: + m.blocks = append(m.blocks, chatBlock{kind: blockReasoning, role: m.accumulator.role, text: part.Text}) + case codersdk.ChatMessagePartTypeToolCall: + kind := blockToolCall + if part.ToolName == contextCompactionToolName { + kind = blockCompaction + } + m.blocks = append(m.blocks, chatBlock{ + kind: kind, + role: m.accumulator.role, + toolName: part.ToolName, + toolID: part.ToolCallID, + args: compactTranscriptJSON(part.Args), + }) + case codersdk.ChatMessagePartTypeToolResult: + kind := blockToolResult + if part.ToolName == contextCompactionToolName { + kind = blockCompaction + } + m.blocks = append(m.blocks, chatBlock{ + kind: kind, + role: m.accumulator.role, + toolName: part.ToolName, + toolID: part.ToolCallID, + result: compactTranscriptJSON(part.Result), + isError: part.IsError, + }) + case codersdk.ChatMessagePartTypeSource: + title := part.Title + if title == "" { + title = part.URL + } + m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)}) + case codersdk.ChatMessagePartTypeFile: + m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[File: %s]", part.MediaType)}) + case codersdk.ChatMessagePartTypeFileReference: + m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)}) + default: + m.blocks = append(m.blocks, chatBlock{kind: blockText, role: m.accumulator.role, text: part.Text}) + } + } + } + + m.blocks = mergeConsecutiveToolBlocks(m.blocks) + + for _, qm := range m.queuedMessages { + for _, part := range qm.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text != "" { + m.blocks = append(m.blocks, chatBlock{ + kind: blockText, + role: codersdk.ChatMessageRoleUser, + text: part.Text, + }) + } + } + } + + for i := range m.blocks { + if i >= len(oldBlocks) || !blockPayloadEqual(m.blocks[i], oldBlocks[i]) { + continue + } + m.blocks[i].cachedRender = oldBlocks[i].cachedRender + m.blocks[i].cachedWidth = oldBlocks[i].cachedWidth + m.blocks[i].cachedExpanded = oldBlocks[i].cachedExpanded + m.blocks[i].cachedCollapsedCount = oldBlocks[i].cachedCollapsedCount + } + + if m.selectedBlock >= len(m.blocks) { + m.selectedBlock = max(len(m.blocks)-1, 0) + } + + m.syncViewportContent() +} + +func (m *chatViewModel) clearPendingStreamAccumulator() { + m.accumulator.reset() + m.rebuildBlocks() +} + +func (m chatViewModel) handleStreamError(err error, wasSpinnerActive bool) (chatViewModel, tea.Cmd) { + if !xerrors.Is(err, io.EOF) { + m.err = err + } + m.streaming, m.streamCloser, m.streamEventCh = false, nil, nil + if m.intentionalClose { + m.intentionalClose = false + return m, nil + } + if !m.shouldReconnect() { + return m, nil + } + m.clearPendingStreamAccumulator() + m.reconnecting = true + m.syncViewportContent() + updated, cmd := m.startStreamWithSpinner(wasSpinnerActive) + if updated.streaming { + updated.err = nil + return updated, cmd + } + return updated, tea.Batch(cmd, scheduleStreamRetry(updated.chatGeneration, 2*time.Second)) +} + +func (m *chatViewModel) getOrCreateMarkdownRenderer(width int) *glamour.TermRenderer { + if m.cachedRendererWidth == width && m.cachedRenderer != nil { + return m.cachedRenderer + } + + m.cachedRendererWidth = width + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(width), + ) + if err != nil { + m.cachedRenderer = nil + return nil + } + + m.cachedRenderer = renderer + return renderer +} + +func (m *chatViewModel) syncViewportContent() { + wrapWidth := m.width + if wrapWidth <= 0 { + wrapWidth = 80 + } + + transcript := renderChatBlocks( + m.styles, + m.blocks, + m.selectedBlock, + m.expandedBlocks, + m.composerFocused, + m.width, + m.getOrCreateMarkdownRenderer(wrapWidth), + ) + + if m.spinnerActive() { + indicator := m.spinner.View() + " " + m.spinnerLabel() + transcript += "\n" + m.styles.dimmedText.Render(indicator) + } + + if transcript != m.lastTranscript { + m.lastTranscript = transcript + m.viewport.SetContent(transcript) + } + if m.autoFollow { + m.viewport.GotoBottom() + } +} + +func blockPayloadEqual(a, b chatBlock) bool { + return a.kind == b.kind && + a.role == b.role && + a.text == b.text && + a.toolName == b.toolName && + a.toolID == b.toolID && + a.args == b.args && + a.result == b.result && + a.isError == b.isError +} + +func (m *chatViewModel) addMessageIfNew(msg codersdk.ChatMessage) bool { + for _, existing := range m.messages { + if existing.ID == msg.ID { + return false + } + } + m.messages = append(m.messages, msg) + return true +} + +func (m chatViewModel) Update(msg tea.Msg) (chatViewModel, tea.Cmd) { + wasSpinnerActive := m.spinnerActive() + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.setComposerWidth() + m.refreshViewport() + return m, nil + + case spinner.TickMsg: + if !m.spinnerActive() { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if m.spinnerVisibleInViewport() { + m.syncViewportContent() + } + return m, cmd + + case tea.KeyMsg: + if msg.Type == tea.KeyShiftTab || msg.String() == "shift+tab" || msg.String() == "backtab" { + m.planMode = m.togglePlanMode() + if !m.draft && m.chat != nil { + return m, m.updatePlanModeCmd() + } + return m, nil + } + if msg.String() == "tab" { + m.composerFocused = !m.composerFocused + if m.composerFocused { + m.composer.Focus() + } else { + m.composer.Blur() + } + m.syncViewportContent() + return m, nil + } + + // Shortcut keys take priority over composer input so the parent model + // can toggle overlays and the chat view can interrupt active chats. + switch msg.Type { + case tea.KeyCtrlP: + return m, func() tea.Msg { return toggleModelPickerMsg{} } + case tea.KeyCtrlD: + return m, func() tea.Msg { return toggleDiffDrawerMsg{} } + case tea.KeyCtrlX: + if !m.isInterruptible() || m.chat == nil || m.interrupting { + return m, nil + } + m.interrupting = true + chatID := m.chat.ID + generation := m.chatGeneration + ctx := m.ctx + client := m.client + return m, apiCmd(func() (codersdk.Chat, error) { + return client.InterruptChat(ctx, chatID) + }, func(chat codersdk.Chat, err error) tea.Msg { + return chatInterruptedMsg{generation: generation, chatID: chatID, chat: chat, err: err} + }) + } + + if m.composerFocused { + if msg.Type == tea.KeyEnter { + if m.pendingAskUserQuestion != nil { + return m, nil + } + return m.sendMessage() + } + var cmd tea.Cmd + m.composer, cmd = m.composer.Update(msg) + m.refreshViewport() + return m, cmd + } + + switch msg.String() { + case "up", "k": + m.viewport.LineUp(3) + m.autoFollow = false + case "down", "j": + m.viewport.LineDown(3) + m.autoFollow = m.viewport.AtBottom() + case "pgup": + m.viewport.HalfViewUp() + m.autoFollow = false + case "pgdown": + m.viewport.HalfViewDown() + m.autoFollow = m.viewport.AtBottom() + case "home": + m.viewport.GotoTop() + m.autoFollow = false + case "end": + m.viewport.GotoBottom() + m.autoFollow = true + default: + return m, nil + } + return m, nil + + case chatOpenedMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + m.metadataResolved = true + var ( + cmds []tea.Cmd + recoveryErr error + ) + if msg.err != nil { + m.metadataErr = msg.err + } else { + m.metadataErr = nil + m.setChat(msg.chat) + m.planMode = m.chat.PlanMode + if m.historyResolved { + recoveryCmd, err := m.recoverPendingAskUserQuestion() + if err != nil { + recoveryErr = err + } else if recoveryCmd != nil { + cmds = append(cmds, recoveryCmd) + } + } + } + updated, cmd := m.finishLoading(wasSpinnerActive) + cmds = append(cmds, cmd) + if recoveryErr != nil && updated.err == nil { + updated.err = recoveryErr + } + return updated, tea.Batch(cmds...) + + case chatPlanModeUpdatedMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if msg.err != nil { + m.planMode = m.togglePlanMode() + m.err = msg.err + } + return m, nil + + case chatHistoryMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + m.historyResolved = true + var ( + cmds []tea.Cmd + recoveryErr error + ) + if msg.err != nil { + m.historyErr = msg.err + } else { + m.historyErr, m.messages, m.lastUsage = nil, msg.messages, nil + for i := len(m.messages) - 1; i >= 0; i-- { + if m.messages[i].Usage != nil { + m.lastUsage = m.messages[i].Usage + break + } + } + m.autoFollow = true + m.rebuildBlocks() + + // Recover pending ask_user_question from history. + if m.chatStatus == codersdk.ChatStatusRequiresAction { + recoveryCmd, err := m.recoverPendingAskUserQuestion() + if err != nil { + recoveryErr = err + } else if recoveryCmd != nil { + cmds = append(cmds, recoveryCmd) + } + } + } + updated, cmd := m.finishLoading(wasSpinnerActive) + cmds = append(cmds, cmd) + if recoveryErr != nil && updated.err == nil { + updated.err = recoveryErr + } + return updated, tea.Batch(cmds...) + + case chatCreatedMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + m.creatingChat = false + if msg.err != nil { + m.err = msg.err + m.restorePendingComposerIfEmpty() + return m, nil + } + m.setChat(msg.chat) + m.draft = false + m.err, m.pendingComposerText = nil, "" + return m.startStreamWithSpinner(wasSpinnerActive) + + case messageSentMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if msg.err != nil { + m.err = msg.err + m.restorePendingComposerIfEmpty() + return m, nil + } + m.err, m.pendingComposerText = nil, "" + if msg.resp.Message != nil { + m.addMessageIfNew(*msg.resp.Message) + } + if msg.resp.Queued && msg.resp.QueuedMessage != nil { + m.queuedMessages = []codersdk.ChatQueuedMessage{*msg.resp.QueuedMessage} + } + m.rebuildBlocks() + return m.startStreamIfReady(wasSpinnerActive) + + case toolResultsSubmittedMsg: + if !m.matchesGeneration(msg.generation) || m.activeChatID != msg.chatID { + return m, nil + } + if msg.err != nil { + if m.pendingAskUserQuestion != nil { + m.pendingAskUserQuestion.Submitting = false + m.pendingAskUserQuestion.Error = msg.err + } + return m, nil + } + m.pendingAskUserQuestion = nil + return m, nil + + case chatInterruptedMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + m.interrupting = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + chat := msg.chat + m.chat, m.chatStatus = &chat, chat.Status + m.syncViewportContent() + return m, m.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), nil) + + case chatStreamEventMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if msg.err != nil { + return m.handleStreamError(msg.err, wasSpinnerActive) + } + updated, cmd := m.handleStreamEvent(msg.event) + return updated, updated.startSpinnerIfNeeded(spinnerState(wasSpinnerActive), cmd) + + case streamRetryMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if m.streaming || !m.shouldReconnect() { + return m, nil + } + updated, cmd := m.startStreamWithSpinner(wasSpinnerActive) + if updated.streaming { + updated.err = nil + return updated, cmd + } + return updated, tea.Batch(cmd, scheduleStreamRetry(updated.chatGeneration, 5*time.Second)) + + case modelsListedMsg: + if msg.err != nil { + return m, nil + } + m.modelPickerFlat = availableChatModels(msg.catalog) + if m.modelPickerCursor >= len(m.modelPickerFlat) { + m.modelPickerCursor = max(len(m.modelPickerFlat)-1, 0) + } + return m, nil + + case gitChangesMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if msg.err != nil { + m.diffErr = msg.err + return m, nil + } + m.gitChanges = msg.changes + return m, nil + + case diffContentsMsg: + if !m.matchesGeneration(msg.generation) { + return m, nil + } + if msg.err != nil { + m.diffErr = msg.err + return m, nil + } + diff := msg.diff + m.diffContents = &diff + return m, nil + + default: + return m, nil + } +} + +func (m chatViewModel) handleStreamEvent(event codersdk.ChatStreamEvent) (chatViewModel, tea.Cmd) { + nextCmd := func(cmd tea.Cmd) tea.Cmd { + if m.streaming && m.streamEventCh != nil { + listenCmd := listenToStream(m.activeChatID, m.chatGeneration, m.streamEventCh) + if cmd != nil { + return tea.Batch(cmd, listenCmd) + } + return listenCmd + } + return cmd + } + + switch event.Type { + case codersdk.ChatStreamEventTypeMessagePart: + if event.MessagePart != nil { + m.accumulator.applyDelta(*event.MessagePart) + m.rebuildBlocks() + } + + case codersdk.ChatStreamEventTypeMessage: + if event.Message != nil { + m.addMessageIfNew(*event.Message) + if event.Message.Usage != nil { + m.lastUsage = event.Message.Usage + } + m.accumulator = streamAccumulator{} + m.reconnecting = false + m.rebuildBlocks() + } + + case codersdk.ChatStreamEventTypeStatus: + if event.Status != nil && event.ChatID == m.activeChatID { + m.chatStatus = event.Status.Status + if m.chat != nil { + m.chat.Status = event.Status.Status + } + + var recoveryCmd tea.Cmd + if event.Status.Status == codersdk.ChatStatusRequiresAction && + m.pendingAskUserQuestion == nil { + var err error + recoveryCmd, err = m.recoverPendingAskUserQuestion() + if err != nil { + m.err = err + } else if recoveryCmd == nil { + recoveryCmd, err = m.recoverPendingAskUserQuestionFromAccumulator() + if err != nil { + m.err = err + } + } + } + + m.syncViewportContent() + if recoveryCmd != nil { + return m, nextCmd(recoveryCmd) + } + } + + case codersdk.ChatStreamEventTypeQueueUpdate: + m.queuedMessages = event.QueuedMessages + m.rebuildBlocks() + + case codersdk.ChatStreamEventTypeRetry: + m.reconnecting = true + m.syncViewportContent() + + case codersdk.ChatStreamEventTypeActionRequired: + if event.ActionRequired == nil { + return m, nextCmd(nil) + } + for _, tc := range event.ActionRequired.ToolCalls { + if tc.ToolName != "ask_user_question" { + continue + } + + state, err := parseAskUserQuestionToolCall(tc) + if err != nil { + return m, func() tea.Msg { + return chatStreamEventMsg{ + generation: m.chatGeneration, + chatID: m.activeChatID, + event: codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeError, + Error: &codersdk.ChatStreamError{ + Message: fmt.Sprintf( + "failed to parse ask_user_question: %v", + err, + ), + }, + }, + } + } + } + + return m, nextCmd(m.showPendingAskUserQuestion(state)) + } + + case codersdk.ChatStreamEventTypeError: + if event.Error != nil { + m.err = xerrors.Errorf("stream error: %s", event.Error.Message) + } + } + + return m, nextCmd(nil) +} + +func (m chatViewModel) View() string { + viewWidth := m.width + if viewWidth <= 0 { + viewWidth = 80 + } + + header := "New Chat (draft)" + if !m.draft && m.chat != nil { + chatID := m.chat.ID.String() + shortID := chatID + if len(chatID) > 8 { + shortID = chatID[:8] + } + header = fmt.Sprintf("%s (%s)", sanitizeTerminalRenderableText(m.chat.Title), shortID) + } + + statusBar := renderStatusBar( + m.styles, + m.chat, + m.chatStatus, + m.lastUsage, + len(m.queuedMessages), + m.interrupting, + m.reconnecting, + viewWidth, + ) + + errorBanner := "" + if m.err != nil { + errorBanner = m.styles.errorText.Render(m.styles.truncate(strings.ReplaceAll(m.err.Error(), "\n", " "), viewWidth)) + } + + composerView := m.styles.composerStyle.Width(max(10, viewWidth-2)).Render(m.composer.View()) + + modeLabel := "exec" + modeBadgeStyle := m.styles.modeBadgeExec + if m.planMode == codersdk.ChatPlanModePlan { + modeLabel = "plan" + modeBadgeStyle = m.styles.modeBadgePlan + } + longHelpParts := []string{"mode: " + modeLabel, "shift+tab: switch mode", "tab: switch focus", "esc: back"} + shortHelpParts := []string{"mode: " + modeLabel, "⇧tab mode", "tab focus", "esc back"} + compactHelpParts := []string{"mode:" + modeLabel, "⇧tab", "tab", "esc"} + if m.composerFocused { + longHelpParts = append(longHelpParts, "enter: send") + shortHelpParts = append(shortHelpParts, "↵ send") + compactHelpParts = append(compactHelpParts, "↵") + } else { + longHelpParts = append(longHelpParts, "↑↓: scroll", "pgup/pgdn: page", "home/end: jump") + shortHelpParts = append(shortHelpParts, "↑↓ scroll", "pg page", "home/end") + compactHelpParts = append(compactHelpParts, "↑↓", "pg", "home/end") + } + if m.isInterruptible() { + longHelpParts = append(longHelpParts, "ctrl+x: interrupt") + shortHelpParts = append(shortHelpParts, "ctrl+x") + compactHelpParts = append(compactHelpParts, "^X") + } + longHelpParts = append(longHelpParts, "ctrl+p: models", "ctrl+d: diff") + shortHelpParts = append(shortHelpParts, "ctrl+p", "ctrl+d") + compactHelpParts = append(compactHelpParts, "^P", "^D") + + renderHelpRow := func(candidates ...string) string { + helpText := fitHelpText(viewWidth, candidates...) + prefix := "" + switch { + case strings.HasPrefix(helpText, "mode: "): + prefix = "mode: " + case strings.HasPrefix(helpText, "mode:"): + prefix = "mode:" + default: + return m.styles.helpText.Render(helpText) + } + + labelStart := len(prefix) + labelEnd := len(helpText) + if idx := strings.IndexAny(helpText[labelStart:], " |│"); idx >= 0 { + labelEnd = labelStart + idx + } + if labelStart == labelEnd { + return m.styles.helpText.Render(helpText) + } + + rendered := m.styles.helpText.Render(helpText[:labelStart]) + modeBadgeStyle.Render(helpText[labelStart:labelEnd]) + if labelEnd < len(helpText) { + rendered += m.styles.helpText.Render(helpText[labelEnd:]) + } + return rendered + } + + helpRow := renderHelpRow( + strings.Join(longHelpParts, " | "), + strings.Join(shortHelpParts, " │ "), + strings.Join(compactHelpParts, " "), + ) + separator := m.styles.separator.Render(strings.Repeat("─", max(viewWidth, 1))) + composerHeight := lipgloss.Height(composerView) + statusBarHeight := 0 + if statusBar != "" { + statusBarHeight = lipgloss.Height(statusBar) + } + errorBannerHeight := 0 + if errorBanner != "" { + errorBannerHeight = lipgloss.Height(errorBanner) + } + nonViewportHeight := 1 + 1 + statusBarHeight + errorBannerHeight + composerHeight + 1 + availableViewportHeight := max(0, m.height-nonViewportHeight) + + viewportView := m.viewport.View() + if m.loading && len(m.blocks) == 0 { + viewportWidth := max(max(m.viewport.Width, viewWidth), 1) + viewportView = lipgloss.Place( + viewportWidth, + max(availableViewportHeight, 1), + lipgloss.Center, + lipgloss.Center, + m.styles.dimmedText.Render("Loading chat..."), + ) + } + viewportView = clampLines(viewportView, availableViewportHeight) + + sections := []string{header} + sections = append(sections, separator, viewportView) + if statusBar != "" { + sections = append(sections, statusBar) + } + if errorBanner != "" { + sections = append(sections, errorBanner) + } + sections = append(sections, composerView, helpRow) + + return strings.Join(sections, "\n") +} diff --git a/cli/exp_agents_cmds.go b/cli/exp_agents_cmds.go new file mode 100644 index 0000000000..bda3540d72 --- /dev/null +++ b/cli/exp_agents_cmds.go @@ -0,0 +1,186 @@ +package cli + +import ( + "context" + "io" + "slices" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + + "github.com/coder/coder/v2/codersdk" +) + +type ( + chatsListedMsg struct { + chats []codersdk.Chat + err error + } + chatOpenedMsg struct { + generation uint64 + chatID uuid.UUID + chat codersdk.Chat + err error + } + chatHistoryMsg struct { + generation uint64 + chatID uuid.UUID + messages []codersdk.ChatMessage + err error + } + chatCreatedMsg struct { + generation uint64 + chatID uuid.UUID + chat codersdk.Chat + err error + } + chatPlanModeUpdatedMsg struct { + generation uint64 + chatID uuid.UUID + err error + } + messageSentMsg struct { + generation uint64 + chatID uuid.UUID + resp codersdk.CreateChatMessageResponse + err error + } + chatInterruptedMsg struct { + generation uint64 + chatID uuid.UUID + chat codersdk.Chat + err error + } + modelsListedMsg struct { + catalog codersdk.ChatModelsResponse + err error + } + gitChangesMsg struct { + generation uint64 + chatID uuid.UUID + changes []codersdk.ChatGitChange + err error + } + diffContentsMsg struct { + generation uint64 + chatID uuid.UUID + diff codersdk.ChatDiffContents + err error + } + chatStreamEventMsg struct { + generation uint64 + chatID uuid.UUID + event codersdk.ChatStreamEvent + err error + } + // showAskUserQuestionMsg tells the parent model to open the + // ask-user-question overlay. + showAskUserQuestionMsg struct { + state *askUserQuestionState + } + // hideAskUserQuestionMsg tells the parent model to close the + // ask-user-question overlay. + hideAskUserQuestionMsg struct{} + // toolResultsSubmittedMsg is sent after the async SubmitToolResults + // call completes. + toolResultsSubmittedMsg struct { + generation uint64 + chatID uuid.UUID + err error + } + streamRetryMsg struct { + generation uint64 + } + toggleModelPickerMsg struct{} + toggleDiffDrawerMsg struct{} +) + +func scheduleStreamRetry(generation uint64, delay time.Duration) tea.Cmd { + return tea.Tick(delay, func(time.Time) tea.Msg { + return streamRetryMsg{generation: generation} + }) +} + +func apiCmd[T any](fn func() (T, error), wrap func(T, error) tea.Msg) tea.Cmd { + return func() tea.Msg { + value, err := fn() + return wrap(value, err) + } +} + +func loadChatHistoryCmd(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID, generation uint64) tea.Cmd { + return apiCmd(func() ([]codersdk.ChatMessage, error) { + var ( + allMessages []codersdk.ChatMessage + opts *codersdk.ChatMessagesPaginationOptions + ) + + for { + resp, err := client.GetChatMessages(ctx, chatID, opts) + if err != nil { + return nil, err + } + + allMessages = append(allMessages, resp.Messages...) + if !resp.HasMore || len(resp.Messages) == 0 { + break + } + + opts = &codersdk.ChatMessagesPaginationOptions{ + BeforeID: resp.Messages[len(resp.Messages)-1].ID, + } + } + + slices.SortStableFunc(allMessages, func(a, b codersdk.ChatMessage) int { + switch { + case a.CreatedAt.Before(b.CreatedAt): + return -1 + case a.CreatedAt.After(b.CreatedAt): + return 1 + case a.ID < b.ID: + return -1 + case a.ID > b.ID: + return 1 + default: + return 0 + } + }) + + return allMessages, nil + }, func(messages []codersdk.ChatMessage, err error) tea.Msg { + return chatHistoryMsg{generation: generation, chatID: chatID, messages: messages, err: err} + }) +} + +func submitAskUserQuestionCmd(client *codersdk.Client, chatID uuid.UUID, generation uint64, state *askUserQuestionState) tea.Cmd { + output, err := buildAskUserQuestionToolResult(state) + if err != nil { + return func() tea.Msg { + return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err} + } + } + + req := codersdk.SubmitToolResultsRequest{ + Results: []codersdk.ToolResult{{ + ToolCallID: state.ToolCallID, + Output: output, + IsError: false, + }}, + } + return apiCmd(func() (struct{}, error) { + return struct{}{}, codersdk.NewExperimentalClient(client).SubmitToolResults(context.Background(), chatID, req) + }, func(_ struct{}, err error) tea.Msg { + return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err} + }) +} + +func listenToStream(chatID uuid.UUID, generation uint64, eventCh <-chan codersdk.ChatStreamEvent) tea.Cmd { + return func() tea.Msg { + event, ok := <-eventCh + if !ok { + return chatStreamEventMsg{generation: generation, chatID: chatID, err: io.EOF} + } + return chatStreamEventMsg{generation: generation, chatID: chatID, event: event} + } +} diff --git a/cli/exp_agents_e2e_helpers_test.go b/cli/exp_agents_e2e_helpers_test.go new file mode 100644 index 0000000000..e383e70ce2 --- /dev/null +++ b/cli/exp_agents_e2e_helpers_test.go @@ -0,0 +1,152 @@ +package cli_test + +import ( + "context" + "os" + "runtime" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func expAgentsPtr[T any](v T) *T { + return &v +} + +func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) { + t.Helper() + + values := coderdtest.DeploymentValues(t) + values.Experiments = []string{string(codersdk.ExperimentAgents)} + + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: values, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + + expClient := codersdk.NewExperimentalClient(client) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + _, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: expAgentsPtr(int64(4096)), + IsDefault: expAgentsPtr(true), + }) + require.NoError(t, err) + + return client, expClient, firstUser.OrganizationID +} + +//nolint:revive // Test helper signature keeps t first for consistency with other helpers. +func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, orgID uuid.UUID, seed string) codersdk.Chat { + t.Helper() + + chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: orgID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: seed, + }, + }, + }) + require.NoError(t, err) + return chat +} + +type expAgentsSession struct { + t *testing.T + pty *ptytest.PTY + errCh <-chan error +} + +func (s *expAgentsSession) expect(ctx context.Context, text string) { + s.t.Helper() + s.pty.ExpectMatchContext(ctx, text) +} + +func (s *expAgentsSession) wait(ctx context.Context) error { + s.t.Helper() + return testutil.RequireReceive(ctx, s.t, s.errCh) +} + +//nolint:unused // Kept as a small PTY helper for future multi-character input. +func (s *expAgentsSession) write(text string) { + s.t.Helper() + s.pty.WriteLine(text) +} + +func (s *expAgentsSession) writeRune(r rune) { + s.t.Helper() + _, err := s.pty.Input().Write([]byte(string(r))) + require.NoError(s.t, err) +} + +func (s *expAgentsSession) enter() { + s.t.Helper() + _, err := s.pty.Input().Write([]byte("\r")) + require.NoError(s.t, err) +} + +func (s *expAgentsSession) esc() { + s.t.Helper() + _, err := s.pty.Input().Write([]byte("\x1b")) + require.NoError(s.t, err) +} + +func (s *expAgentsSession) ctrlC() { + s.t.Helper() + _, err := s.pty.Input().Write([]byte{3}) + require.NoError(s.t, err) +} + +func (s *expAgentsSession) quit() { + s.t.Helper() + s.writeRune('q') +} + +//nolint:revive // Test helper signature keeps t first for consistency with other helpers. +func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *expAgentsSession { + t.Helper() + + // Reading to / writing from the PTY is flaky on non-linux systems. + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux") + } + + fullArgs := append([]string{"exp", "agents"}, args...) + inv, root := clitest.New(t, fullArgs...) + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t) + tty, err := os.OpenFile(pty.Name(), os.O_RDWR, 0) + require.NoError(t, err) + t.Cleanup(func() { + _ = tty.Close() + }) + + inv.Stdin = tty + inv.Stdout = tty + inv.Stderr = tty + + errCh := make(chan error, 1) + tGo(t, func() { + errCh <- inv.WithContext(ctx).Run() + }) + + return &expAgentsSession{t: t, pty: pty, errCh: errCh} +} diff --git a/cli/exp_agents_e2e_test.go b/cli/exp_agents_e2e_test.go new file mode 100644 index 0000000000..b43db58037 --- /dev/null +++ b/cli/exp_agents_e2e_test.go @@ -0,0 +1,87 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/testutil" +) + +func TestExpAgentsE2E(t *testing.T) { + t.Parallel() + + t.Run("EmptyStateBoot", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _, _ := setupExpAgentsBackend(t) + session := startExpAgentsSession(t, ctx, client) + + session.expect(ctx, "No chats yet. Press n to start a new chat.") + session.quit() + require.NoError(t, session.wait(ctx)) + }) + + t.Run("ListAndNavigate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, expClient, orgID := setupExpAgentsBackend(t) + + _ = seedChat(t, ctx, expClient, orgID, "alpha nav seed") + _ = seedChat(t, ctx, expClient, orgID, "bravo nav seed") + _ = seedChat(t, ctx, expClient, orgID, "charlie nav seed") + + session := startExpAgentsSession(t, ctx, client) + + session.expect(ctx, "charlie nav seed") + session.expect(ctx, "enter: open") + session.enter() + session.expect(ctx, "esc") + session.esc() + session.expect(ctx, "enter: open") + session.quit() + require.NoError(t, session.wait(ctx)) + }) + + t.Run("SearchFilter", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, expClient, orgID := setupExpAgentsBackend(t) + + _ = seedChat(t, ctx, expClient, orgID, "alpha filter seed") + _ = seedChat(t, ctx, expClient, orgID, "zulu filter seed") + + session := startExpAgentsSession(t, ctx, client) + + session.expect(ctx, "alpha filter seed") + session.expect(ctx, "enter: open") + session.writeRune('/') + session.expect(ctx, "/ ") + for _, r := range "zzzznotamatch" { + session.writeRune(r) + } + session.expect(ctx, "No matches.") + session.ctrlC() + require.NoError(t, session.wait(ctx)) + }) + + t.Run("ExistingChatHistory", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, expClient, orgID := setupExpAgentsBackend(t) + + chat := seedChat(t, ctx, expClient, orgID, "direct open seed") + session := startExpAgentsSession(t, ctx, client, chat.ID.String()) + + session.expect(ctx, "direct open seed") + session.expect(ctx, "esc") + session.esc() + session.expect(ctx, "enter: open") + session.quit() + require.NoError(t, session.wait(ctx)) + }) +} diff --git a/cli/exp_agents_helpers.go b/cli/exp_agents_helpers.go new file mode 100644 index 0000000000..0e0b892469 --- /dev/null +++ b/cli/exp_agents_helpers.go @@ -0,0 +1,33 @@ +package cli + +import ( + "regexp" + "strings" + "unicode" +) + +var terminalEscapeSequenceRegexp = regexp.MustCompile( + `\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` + + "›" + `[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` + + `\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|` + + "" + `[^\x07\x1b]*(?:\x07|\x1b\\)|` + + `\x1b[^\[\]].`, +) + +func sanitizeTerminalRenderableText(text string) string { + if text == "" { + return "" + } + + text = terminalEscapeSequenceRegexp.ReplaceAllString(text, "") + return strings.Map(func(r rune) rune { + switch r { + case '\n', '\t': + return r + } + if unicode.IsControl(r) { + return -1 + } + return r + }, text) +} diff --git a/cli/exp_agents_list.go b/cli/exp_agents_list.go new file mode 100644 index 0000000000..d6ea6a1d5f --- /dev/null +++ b/cli/exp_agents_list.go @@ -0,0 +1,482 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + + "github.com/coder/coder/v2/codersdk" +) + +type ( + openSelectedChatMsg struct { + chatID uuid.UUID + } + openDraftChatMsg struct{} + refreshChatsMsg struct{} +) + +type chatDisplayRow struct { + chat codersdk.Chat + depth int + isSubagent bool + childCount int + isExpanded bool +} + +type chatListModel struct { + styles tuiStyles + chats []codersdk.Chat + expanded map[uuid.UUID]bool + cursor int + offset int + loading bool + err error + search textinput.Model + searching bool + spinner spinner.Model + width int + height int +} + +func newChatListModel(styles tuiStyles) chatListModel { + search := textinput.New() + search.Placeholder = "Search chats..." + search.Prompt = "/ " + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styles.dimmedText + + return chatListModel{ + styles: styles, + expanded: make(map[uuid.UUID]bool), + loading: true, + search: search, + spinner: s, + } +} + +func (m chatListModel) searchQuery() string { + return strings.TrimSpace(strings.ToLower(m.search.Value())) +} + +func (m chatListModel) filteredChats() []codersdk.Chat { + query := m.searchQuery() + if query == "" { + return m.chats + } + + filtered := make([]codersdk.Chat, 0, len(m.chats)) + for _, chat := range m.chats { + if strings.Contains(strings.ToLower(chat.Title), query) || strings.Contains(strings.ToLower(chat.ID.String()), query) { + filtered = append(filtered, chat) + continue + } + if chat.LastError != nil && strings.Contains(strings.ToLower(*chat.LastError), query) { + filtered = append(filtered, chat) + } + } + + return filtered +} + +func (m chatListModel) displayRows() []chatDisplayRow { + filtered := m.filteredChats() + if len(filtered) == 0 { + return nil + } + + queryActive := m.searchQuery() != "" + chatsByID := make(map[uuid.UUID]codersdk.Chat, len(m.chats)) + included := make(map[uuid.UUID]struct{}, len(filtered)) + for _, chat := range m.chats { + chatsByID[chat.ID] = chat + } + for _, chat := range filtered { + included[chat.ID] = struct{}{} + if !queryActive { + continue + } + for parentID := chat.ParentChatID; parentID != nil; { + parent, ok := chatsByID[*parentID] + if !ok { + break + } + included[parent.ID] = struct{}{} + parentID = parent.ParentChatID + } + } + + childrenOf := make(map[uuid.UUID][]codersdk.Chat) + roots := make([]codersdk.Chat, 0, len(included)) + for _, chat := range m.chats { + if _, ok := included[chat.ID]; !ok { + continue + } + if chat.ParentChatID == nil { + roots = append(roots, chat) + continue + } + if _, ok := included[*chat.ParentChatID]; ok { + childrenOf[*chat.ParentChatID] = append(childrenOf[*chat.ParentChatID], chat) + } + } + + rows := make([]chatDisplayRow, 0, len(included)) + var appendRows func(codersdk.Chat, int) + appendRows = func(chat codersdk.Chat, depth int) { + children := childrenOf[chat.ID] + isExpanded := m.expanded[chat.ID] + if queryActive && len(children) > 0 { + isExpanded = true + } + + rows = append(rows, chatDisplayRow{ + chat: chat, + depth: depth, + isSubagent: depth > 0, + childCount: len(children), + isExpanded: isExpanded, + }) + if !isExpanded { + return + } + for _, child := range children { + appendRows(child, depth+1) + } + } + + for _, root := range roots { + appendRows(root, 0) + } + + return rows +} + +func (m chatListModel) selectedRow() (chatDisplayRow, bool) { + rows := m.displayRows() + if len(rows) == 0 || m.cursor < 0 || m.cursor >= len(rows) { + return chatDisplayRow{}, false + } + return rows[m.cursor], true +} + +func (m *chatListModel) moveCursorToChat(chatID uuid.UUID) { + rows := m.displayRows() + for i, row := range rows { + if row.chat.ID == chatID { + m.cursor = i + return + } + } +} + +type chatExpansionIntent int + +const ( + chatExpansionToggle chatExpansionIntent = iota + chatExpansionExpand + chatExpansionCollapse +) + +func (m *chatListModel) updateSelectedRowExpansion(intent chatExpansionIntent) bool { + row, ok := m.selectedRow() + if !ok { + return false + } + if row.childCount == 0 { + if intent == chatExpansionExpand || row.chat.ParentChatID == nil { + return false + } + parentID := *row.chat.ParentChatID + m.expanded[parentID] = false + m.moveCursorToChat(parentID) + return true + } + + switch intent { + case chatExpansionExpand: + if row.isExpanded { + return false + } + m.expanded[row.chat.ID] = true + case chatExpansionCollapse: + if row.isExpanded { + m.expanded[row.chat.ID] = false + return true + } + if row.chat.ParentChatID == nil || !m.expanded[*row.chat.ParentChatID] { + return false + } + parentID := *row.chat.ParentChatID + m.expanded[parentID] = false + m.moveCursorToChat(parentID) + return true + case chatExpansionToggle: + if row.isExpanded && !m.expanded[row.chat.ID] { + return false + } + m.expanded[row.chat.ID] = !row.isExpanded + default: + return false + } + + return true +} + +func (m chatListModel) selectedChat() *codersdk.Chat { + row, ok := m.selectedRow() + if !ok { + return nil + } + return &row.chat +} + +func (m *chatListModel) normalizeCursor() { + total := len(m.displayRows()) + if total == 0 { + m.cursor = 0 + m.offset = 0 + return + } + m.cursor = min(max(m.cursor, 0), total-1) + m.offset, _ = m.visibleWindow(total) +} + +func (m chatListModel) visibleChatCount() int { + overhead := 3 + if m.searching { + overhead += 2 + } + + visibleCount := m.height - overhead + if visibleCount < 3 { + visibleCount = 3 + } + return visibleCount +} + +func (m chatListModel) visibleWindow(total int) (start int, end int) { + if total == 0 { + return 0, 0 + } + + visibleCount := m.visibleChatCount() + maxOffset := max(total-visibleCount, 0) + cursor := min(max(m.cursor, 0), total-1) + start = min(max(min(max(m.offset, 0), maxOffset), cursor-visibleCount+1), cursor) + end = min(start+visibleCount, total) + return start, end +} + +func (m chatListModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m chatListModel) Update(msg tea.Msg) (chatListModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.normalizeCursor() + return m, nil + + case spinner.TickMsg: + if m.loading { + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + return m, nil + + case chatsListedMsg: + m.chats = msg.chats + m.err = msg.err + m.loading = false + m.normalizeCursor() + return m, nil + + case tea.KeyMsg: + key := msg.String() + if m.searching { + switch key { + case "esc": + if m.search.Value() != "" { + m.search.SetValue("") + } + m.search.Blur() + m.searching = false + m.normalizeCursor() + return m, nil + case "enter": + m.search.Blur() + m.searching = false + m.normalizeCursor() + return m, nil + default: + m.search, cmd = m.search.Update(msg) + m.normalizeCursor() + m.offset = 0 + return m, cmd + } + } + + navigationHandled, normalizeNavigation := true, true + switch key { + case "/", "ctrl+f": + m.searching = true + m.search.Focus() + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + case "right", "l": + normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionExpand) + case "left", "h": + normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionCollapse) + case "x": + normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionToggle) + default: + navigationHandled = false + } + if navigationHandled { + if normalizeNavigation { + m.normalizeCursor() + } + return m, nil + } + + switch key { + case "enter": + selected := m.selectedChat() + if selected == nil { + return m, nil + } + return m, func() tea.Msg { + return openSelectedChatMsg{chatID: selected.ID} + } + case "n": + return m, func() tea.Msg { + return openDraftChatMsg{} + } + case "r": + m.loading = true + m.err = nil + return m, func() tea.Msg { + return refreshChatsMsg{} + } + case "q": + return m, tea.Quit + } + } + + return m, nil +} + +func (m chatListModel) View() string { + if m.loading { + return m.spinner.View() + " Loading chats…" + } + + if m.err != nil { + return m.styles.errorText.Render(m.err.Error()) + "\n" + m.styles.helpText.Render("Press r to retry") + } + + rows := m.displayRows() + lines := make([]string, 0, len(rows)+3) + if m.searching { + lines = append(lines, m.styles.searchInput.Render(m.search.View())) + } + + if len(rows) == 0 { + if strings.TrimSpace(m.search.Value()) != "" { + lines = append(lines, m.styles.dimmedText.Render("No matches.")) + } else { + lines = append(lines, m.styles.dimmedText.Render("No chats yet. Press n to start a new chat.")) + } + help := fitHelpText( + m.width, + "/: search • n: new chat • r: refresh • q: quit", + "/ search • n new • r refresh • q quit", + "/ • n • r • q", + ) + lines = append(lines, m.styles.helpText.Render(help)) + return strings.Join(lines, "\n") + } + + statusWidth := 12 + start, end := m.visibleWindow(len(rows)) + for i := start; i < end; i++ { + row := rows[i] + rowPrefix := " " + rowStyle := m.styles.normalItem + if i == m.cursor { + rowPrefix = "> " + rowStyle = m.styles.selectedItem + } + if row.depth > 0 { + rowPrefix += strings.Repeat(" ", row.depth) + } + if row.childCount > 0 { + if row.isExpanded { + rowPrefix += "▼ " + } else { + rowPrefix += "▶ " + } + } + + extraText := "" + extra := "" + if row.childCount > 0 { + extraText = fmt.Sprintf(" (%d subagents)", row.childCount) + extra = m.styles.dimmedText.Render(extraText) + } + + titleWidth := max(m.width-statusWidth-18-len(rowPrefix)-len(extraText), 20) + title := m.styles.truncate(sanitizeTerminalRenderableText(row.chat.Title), titleWidth) + status := m.styles.statusColor(row.chat.Status).Render(string(row.chat.Status)) + rowText := fmt.Sprintf("%s%s %s %s%s", rowPrefix, rowStyle.Render(title), status, m.styles.dimmedText.Render(timeAgo(row.chat.UpdatedAt)), extra) + lines = append(lines, rowText) + + if row.chat.Status == codersdk.ChatStatusError && row.chat.LastError != nil { + errWidth := max(m.width-4, 20) + errPrefix := " " + if row.depth > 0 { + errPrefix += strings.Repeat(" ", row.depth) + } + lines = append(lines, errPrefix+m.styles.dimmedText.Render(m.styles.truncate(sanitizeTerminalRenderableText(*row.chat.LastError), errWidth))) + } + } + + lines = append(lines, "") + help := fitHelpText( + m.width, + "↑/k: up • ↓/j: down • →/l: expand • ←/h: collapse • x: toggle • enter: open • /: search • n: new chat • r: refresh • q: quit", + "↑/k up • ↓/j down • →/l expand • ←/h collapse • x toggle • ↵ open • / search • n new • q quit", + "↑↓ nav • →← fold • x toggle • ↵ open • / search • n new • q quit", + "↑↓ • →← • x • ↵ • / • n • q", + ) + lines = append(lines, m.styles.helpText.Render(help)) + return strings.Join(lines, "\n") +} + +func timeAgo(t time.Time) string { + elapsed := time.Since(t) + if elapsed < time.Minute { + return "just now" + } + if elapsed < time.Hour { + return fmt.Sprintf("%dm ago", int(elapsed/time.Minute)) + } + if elapsed < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(elapsed/time.Hour)) + } + return fmt.Sprintf("%dd ago", int(elapsed/(24*time.Hour))) +} diff --git a/cli/exp_agents_model.go b/cli/exp_agents_model.go new file mode 100644 index 0000000000..eba9e6517b --- /dev/null +++ b/cli/exp_agents_model.go @@ -0,0 +1,516 @@ +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.gitChanges == nil || m.chat.diffContents == nil || m.chat.diffErr != nil { + m.chat.diffErr = nil + chatID := m.chat.chat.ID + generation := m.chat.chatGeneration + return tea.Batch(apiCmd(func() ([]codersdk.ChatGitChange, error) { return m.client.GetChatGitChanges(m.ctx, chatID) }, func(changes []codersdk.ChatGitChange, err error) tea.Msg { + return gitChangesMsg{generation: generation, chatID: chatID, changes: changes, err: err} + }), apiCmd(func() (codersdk.ChatDiffContents, error) { return m.client.GetChatDiffContents(m.ctx, 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.gitChanges, 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, gitChangesMsg, 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) +} diff --git a/cli/exp_agents_render.go b/cli/exp_agents_render.go new file mode 100644 index 0000000000..8ff7fda066 --- /dev/null +++ b/cli/exp_agents_render.go @@ -0,0 +1,883 @@ +package cli + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + + "github.com/coder/coder/v2/codersdk" +) + +const ( + contextCompactionToolName = "context_compaction" + toolBlockIndent = " " + toolDetailIndent = " " + toolSummaryFallbackWidth = 48 + pendingToolIcon = "○" + reasoningPrefix = "thinking: " +) + +func compactTranscriptJSON(raw json.RawMessage) string { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return "" + } + + var builder bytes.Buffer + if err := json.Compact(&builder, raw); err == nil { + return builder.String() + } + + return string(raw) +} + +func toolBaseName(name string) string { + name = strings.TrimSpace(name) + name = strings.TrimPrefix(name, "coder_") + name = strings.TrimPrefix(name, "github__") + return strings.Join(strings.Fields(name), " ") +} + +func humanizeToolName(name string) string { + name = strings.ReplaceAll(toolBaseName(name), "_", " ") + name = strings.Join(strings.Fields(name), " ") + if name == "" { + return "tool" + } + return name +} + +func normalizeToolName(name string) string { + if toolBaseName(name) == "" { + return "" + } + return strings.ReplaceAll(strings.ToLower(humanizeToolName(name)), " ", "_") +} + +func summarizeToolContent(toolName, raw string, fields ...string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + if summary := toolObjectSummary(toolName, parsed); summary != "" { + return summary + } + if value := firstStringField(parsed, fields...); value != "" { + return strconv.Quote(value) + } + if value := firstShortStringValue(parsed); value != "" { + return strconv.Quote(value) + } + } + compact := compactTranscriptJSON(json.RawMessage(raw)) + if compact == "" { + return "" + } + compactRunes := []rune(compact) + if len(compactRunes) <= toolSummaryFallbackWidth { + return compact + } + return string(compactRunes[:toolSummaryFallbackWidth-1]) + "…" +} + +var toolArgsSummary = summarizeToolContent + +func toolResultSummary(toolName, argsJSON, resultJSON string) string { + return cmp.Or( + summarizeToolContent(toolName, argsJSON), + summarizeToolContent(toolName, resultJSON), + "null", + ) +} + +func toolObjectSummary(toolName string, parsed any) string { + normalized := normalizeToolName(toolName) + switch { + case normalized == "execute" || normalized == "execute_command" || normalized == "run_command": + if command := firstStringField(parsed, "command", "cmd", "script", "input"); command != "" { + return strconv.Quote(command) + } + case strings.Contains(normalized, "read_file") || strings.Contains(normalized, "write_file") || strings.Contains(normalized, "delete_file") || strings.Contains(normalized, "stat_file"): + if path := firstStringField(parsed, "path", "file_path", "filename"); path != "" { + return "(" + path + ")" + } + case normalized == "get_pull_request": + owner := firstStringField(parsed, "owner") + repo := firstStringField(parsed, "repo", "repository") + switch { + case owner != "" && repo != "": + return "(" + owner + "/" + repo + ")" + case repo != "": + return "(" + repo + ")" + } + case strings.Contains(normalized, "workspace"): + if workspace := firstStringField(parsed, "workspace_name", "name", "workspace"); workspace != "" { + return "(" + workspace + ")" + } + } + return "" +} + +func firstStringField(value any, keys ...string) string { + object, ok := value.(map[string]any) + if !ok { + return "" + } + for _, key := range keys { + fieldValue, ok := object[key] + if !ok { + continue + } + if text := firstShortStringValue(fieldValue); text != "" { + return text + } + } + return "" +} + +func firstShortStringValue(value any) string { + switch typed := value.(type) { + case string: + trimmed := strings.Join(strings.Fields(strings.TrimSpace(typed)), " ") + if trimmed == "" { + return "" + } + return trimmed + case []any: + for _, item := range typed { + if text := firstShortStringValue(item); text != "" { + return text + } + } + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + if text := firstShortStringValue(typed[key]); text != "" { + return text + } + } + } + return "" +} + +func toolDisplayLabel(toolName string, kind chatBlockKind, collapsedCount int) string { + label := humanizeToolName(toolName) + if collapsedCount <= 1 { + return label + } + + switch kind { + case blockToolCall: + return label + "..." + case blockToolResult: + return fmt.Sprintf("%s (x%d)", label, collapsedCount) + default: + return label + } +} + +func renderToolLine(styles tuiStyles, labelStyle lipgloss.Style, icon, label, summary string, width int) string { + label = sanitizeTerminalRenderableText(label) + summary = sanitizeTerminalRenderableText(summary) + header := toolBlockIndent + labelStyle.Render(icon) + " " + label + if summary == "" || width <= 0 { + return header + } + available := width - lipgloss.Width(header) - 1 + preview := styles.truncate(summary, max(available, 0)) + if preview == "" { + return header + } + return header + " " + styles.dimmedText.Render(preview) +} + +func renderToolDetail(styles tuiStyles, label, value string, width int) string { + value = sanitizeTerminalRenderableText(value) + if strings.TrimSpace(value) == "" { + return "" + } + prefix := toolDetailIndent + label + ": " + wrapped := wrapPreservingNewlines(value, contentWidth(width, lipgloss.Width(prefix))) + lines := strings.Split(wrapped, "\n") + for i := range lines { + if i == 0 { + lines[i] = prefix + lines[i] + continue + } + lines[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + lines[i] + } + return styles.dimmedText.Render(strings.Join(lines, "\n")) +} + +func renderExpandedToolBlock(styles tuiStyles, labelStyle lipgloss.Style, icon, toolName, args, result string, width int) string { + lines := []string{toolBlockIndent + labelStyle.Render(icon) + " " + humanizeToolName(toolName)} + if argsLine := renderToolDetail(styles, "args", args, width); argsLine != "" { + lines = append(lines, argsLine) + } + if resultLine := renderToolDetail(styles, "result", result, width); resultLine != "" { + lines = append(lines, resultLine) + } + return strings.Join(lines, "\n") +} + +func toolResultIconAndStyle(styles tuiStyles, block chatBlock) (string, lipgloss.Style) { + if block.isError { + return "✗", styles.errorText + } + return "✓", styles.toolSuccess +} + +func renderToolCallBlock(styles tuiStyles, block chatBlock, width int) string { + if block.toolName == contextCompactionToolName { + return renderCompaction(styles, width) + } + + return renderToolLine( + styles, + styles.toolPending, + pendingToolIcon, + toolDisplayLabel(block.toolName, block.kind, block.collapsedCount), + summarizeToolContent(block.toolName, block.args), + width, + ) +} + +func renderToolResultBlock(styles tuiStyles, block chatBlock, width int) string { + if block.toolName == contextCompactionToolName { + return renderCompaction(styles, width) + } + icon, labelStyle := toolResultIconAndStyle(styles, block) + + summary := summarizeToolContent(block.toolName, block.args) + if summary == "" && block.isError { + summary = summarizeToolContent("", block.result, "error", "message", "detail", "stderr") + } + if summary == "" { + summary = toolResultSummary(block.toolName, "", block.result) + } + return renderToolLine( + styles, + labelStyle, + icon, + toolDisplayLabel(block.toolName, block.kind, block.collapsedCount), + summary, + width, + ) +} + +func renderCompaction(styles tuiStyles, width int) string { + banner := styles.compaction.Render("🗜️ Context compacted") + if width <= 0 { + return banner + } + return lipgloss.PlaceHorizontal(width, lipgloss.Center, banner) +} + +func contentWidth(width, inset int) int { + if width <= 0 { + return 80 + } + return max(width-inset, 1) +} + +func renderOverlayFrame(styles tuiStyles, width int, sections ...string) string { + sections = slices.DeleteFunc(sections, func(section string) bool { return section == "" }) + return styles.overlayBorder.Width(contentWidth(width, 6)).Render(strings.Join(sections, "\n\n")) +} + +func diffMetadataLines(diff codersdk.ChatDiffContents) []string { + var lines []string + if diff.Branch != nil && *diff.Branch != "" { + lines = append(lines, fmt.Sprintf("Branch: %s", *diff.Branch)) + } + if diff.PullRequestURL != nil && *diff.PullRequestURL != "" { + lines = append(lines, fmt.Sprintf("PR: %s", *diff.PullRequestURL)) + } + return lines +} + +func renderChatDiffSummary(diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange) string { + lines := diffMetadataLines(diff) + if len(changes) == 0 { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, "No changes detected.") + return strings.Join(lines, "\n") + } + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, "Files changed:") + for _, change := range changes { + path := sanitizeTerminalRenderableText(change.FilePath) + if change.ChangeType == "renamed" && change.OldPath != nil && *change.OldPath != "" { + path = fmt.Sprintf("%s → %s", sanitizeTerminalRenderableText(*change.OldPath), path) + } + lines = append(lines, fmt.Sprintf(" %-8s %s", change.ChangeType, path)) + } + return strings.Join(lines, "\n") +} + +func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange, width, height int) string { + innerWidth := contentWidth(width, 6) + headerBits := []string{styles.title.Render("Diff")} + if meta := diffMetadataLines(diff); len(meta) > 0 { + headerBits = append(headerBits, styles.subtitle.Render(strings.Join(meta, " • "))) + } + summary := renderChatDiffSummary(diff, changes) + diffBody := sanitizeTerminalRenderableText(diff.Diff) + if strings.TrimSpace(diffBody) == "" { + diffBody = styles.dimmedText.Render("No diff contents.") + } + help := styles.helpText.Render("Esc to close") + overhead := countRenderedLines(strings.Join(headerBits, "\n")) + countRenderedLines(summary) + countRenderedLines(help) + 4 + availableBodyLines := max(height-overhead, 0) + if height <= 0 { + availableBodyLines = 12 + } + wrappedDiff := wrapPreservingNewlines(diffBody, innerWidth) + if availableBodyLines == 0 { + wrappedDiff = "" + } else { + wrappedDiff = clampLines(wrappedDiff, availableBodyLines) + } + return renderOverlayFrame(styles, width, strings.Join(headerBits, "\n"), summary, wrappedDiff, help) +} + +func renderModelPicker(styles tuiStyles, catalog codersdk.ChatModelsResponse, selected string, cursor int, width, height int) string { + innerWidth := contentWidth(width, 6) + lines := []string{styles.title.Render("Select Model")} + cursorLine := 0 + hasModels := false + flatIndex := 0 + for _, provider := range catalog.Providers { + if len(provider.Models) == 0 { + continue + } + lines = append(lines, styles.subtitle.Render(provider.Provider)) + if !provider.Available { + reason := string(provider.UnavailableReason) + if reason == "" { + reason = "unavailable" + } + lines = append(lines, " "+styles.dimmedText.Render(reason)) + lines = append(lines, "") + continue + } + for _, model := range provider.Models { + hasModels = true + name := model.DisplayName + if strings.TrimSpace(name) == "" { + name = model.Model + } + marker := " " + if flatIndex == cursor { + marker = "> " + } + rowStyle := styles.normalItem + if model.ID == selected { + rowStyle = styles.selectedItem + } + lines = append(lines, marker+rowStyle.Render(styles.truncate(name, max(innerWidth-2, 0)))) + if flatIndex == cursor { + cursorLine = len(lines) - 1 + } + flatIndex++ + } + lines = append(lines, "") + } + if !hasModels { + lines = append(lines, styles.dimmedText.Render("No models available.")) + lines = append(lines, "") + } + help := styles.helpText.Render("Esc to close, Enter to select") + contentLines := lines + maxContentLines := max(height-countRenderedLines(help)-4, 1) + if height <= 0 { + maxContentLines = len(contentLines) + } + windowStart := 0 + if cursorLine >= maxContentLines { + windowStart = cursorLine - maxContentLines + 1 + } + maxWindowStart := max(len(contentLines)-maxContentLines, 0) + windowStart = min(windowStart, maxWindowStart) + windowEnd := min(windowStart+maxContentLines, len(contentLines)) + content := append([]string(nil), contentLines[windowStart:windowEnd]...) + content = append(content, help) + return renderOverlayFrame(styles, width, strings.Join(content, "\n")) +} + +func renderAskUserQuestion(styles tuiStyles, state *askUserQuestionState, width, height int) string { + if state == nil || len(state.Questions) == 0 { + return "" + } + if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) { + return "" + } + + innerWidth := contentWidth(width, 6) + question := state.Questions[state.CurrentIndex] + sections := []string{styles.title.Render(fmt.Sprintf("Plan Question %d/%d", state.CurrentIndex+1, len(state.Questions)))} + if question.Header != "" { + sections = append(sections, styles.subtitle.Render(sanitizeTerminalRenderableText(question.Header))) + } + sections = append(sections, wrapPreservingNewlines(sanitizeTerminalRenderableText(question.Question), innerWidth)) + + if state.Submitting { + sections = append(sections, styles.dimmedText.Render("Submitting answers...")) + return renderOverlayFrame(styles, width, sections...) + } + + optionLines := make([]string, 0, len(question.Options)+3) + for i, option := range question.Options { + label := strings.TrimSpace(sanitizeTerminalRenderableText(option.Label)) + if label == "" { + label = "(empty option)" + } + label = styles.truncate(label, max(innerWidth-2, 0)) + row := " " + label + if i == state.OptionCursor { + row = styles.selectedItem.Render("> " + label) + } + optionLines = append(optionLines, row) + } + + otherLabel := styles.truncate("Other (type custom answer)", max(innerWidth-2, 0)) + otherRow := " " + otherLabel + if state.OptionCursor == len(question.Options) { + otherRow = styles.selectedItem.Render("> " + otherLabel) + } + optionLines = append(optionLines, otherRow) + if state.OtherMode { + optionLines = append(optionLines, "", state.OtherInput.View()) + } + sections = append(sections, strings.Join(optionLines, "\n")) + + if state.Error != nil { + sections = append(sections, styles.errorText.Render(wrapPreservingNewlines( + "Error: "+sanitizeTerminalRenderableText(state.Error.Error()), + innerWidth, + ))) + } + + longHelpParts := []string{"↑/↓ navigate", "enter select"} + shortHelpParts := []string{"↑↓", "↵"} + compactHelpParts := []string{"↑↓", "↵"} + if state.CurrentIndex > 0 { + longHelpParts = append(longHelpParts, "←/h back") + shortHelpParts = append(shortHelpParts, "←/h") + compactHelpParts = append(compactHelpParts, "←") + } + if state.OtherMode { + longHelpParts = append(longHelpParts, "esc cancel input") + shortHelpParts = append(shortHelpParts, "esc input") + compactHelpParts = append(compactHelpParts, "esc") + } + sections = append(sections, styles.helpText.Render(fitHelpText( + innerWidth, + strings.Join(longHelpParts, " | "), + strings.Join(shortHelpParts, " │ "), + strings.Join(compactHelpParts, " "), + ))) + + _ = height + return renderOverlayFrame(styles, width, sections...) +} + +//nolint:revive // Signature is dictated by the chat TUI view code. +func renderChatBlocks(styles tuiStyles, blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool, composerFocused bool, width int, renderers ...*glamour.TermRenderer) string { + if len(blocks) == 0 { + return "" + } + + var renderer *glamour.TermRenderer + if len(renderers) > 0 { + renderer = renderers[0] + } + activeSelection := -1 + if !composerFocused { + activeSelection = selectedBlock + } + visibleIndices := collapseConsecutiveSameNameBlocks(blocks, activeSelection, expandedBlocks) + rendered := make([]string, 0, len(visibleIndices)) + for _, index := range visibleIndices { + blockView := blocks[index].cachedRender + if blockView == "" || + blocks[index].cachedWidth != width || + blocks[index].cachedExpanded != expandedBlocks[index] || + blocks[index].cachedCollapsedCount != blocks[index].collapsedCount { + blockView = renderBlock(styles, blocks[index], expandedBlocks[index], width, renderer) + blocks[index].cachedRender = blockView + blocks[index].cachedWidth = width + blocks[index].cachedExpanded = expandedBlocks[index] + blocks[index].cachedCollapsedCount = blocks[index].collapsedCount + } + if index == activeSelection { + blockView = styles.selectedBlock.Render(blockView) + } + rendered = append(rendered, blockView) + } + return strings.Join(rendered, "\n") +} + +//nolint:revive // Signature is dictated by the chat TUI view code. +func renderStatusBar(styles tuiStyles, chat *codersdk.Chat, status codersdk.ChatStatus, usage *codersdk.ChatMessageUsage, queueCount int, interrupting, reconnecting bool, width int) string { + _ = chat + parts := []string{styles.statusColor(status).Render(string(status))} + if usage != nil && usage.TotalTokens != nil && usage.ContextLimit != nil { + total := *usage.TotalTokens + limit := *usage.ContextLimit + if limit > 0 { + tokenText := fmt.Sprintf("tokens: %d/%d", total, limit) + pct := float64(total) / float64(limit) * 100 + switch { + case pct > 95: + tokenText = styles.criticalText.Render(tokenText) + case pct > 80: + tokenText = styles.warningText.Render(tokenText) + } + parts = append(parts, tokenText) + } + } + if queueCount > 0 { + parts = append(parts, fmt.Sprintf("queued: %d", queueCount)) + } + if interrupting { + parts = append(parts, styles.warningText.Render("interrupting…")) + } + if reconnecting { + parts = append(parts, styles.warningText.Render("reconnecting…")) + } + line := strings.Join(parts, styles.separator.Render(" │ ")) + bar := styles.statusBar + if width > 0 { + bar = bar.MaxWidth(width) + } + return bar.Render(line) +} + +func collapseConsecutiveSameNameBlocks(blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool) []int { + if len(blocks) == 0 { + return nil + } + + for i := range blocks { + blocks[i].collapsedCount = 0 + } + + visibleIndices := make([]int, 0, len(blocks)) + for i := 0; i < len(blocks); { + runEnd := i + 1 + for runEnd < len(blocks) && canCollapseToolBlocks(blocks[i], blocks[runEnd]) { + runEnd++ + } + + if runEnd-i < 2 || hasExpandedToolBlock(expandedBlocks, i, runEnd) { + for j := i; j < runEnd; j++ { + visibleIndices = append(visibleIndices, j) + } + i = runEnd + continue + } + + representative := i + if selectedBlock >= i && selectedBlock < runEnd { + representative = selectedBlock + } + blocks[representative].collapsedCount = runEnd - i + visibleIndices = append(visibleIndices, representative) + i = runEnd + } + + return visibleIndices +} + +func canCollapseToolBlocks(a, b chatBlock) bool { + if a.kind != b.kind { + return false + } + if a.kind != blockToolCall && a.kind != blockToolResult { + return false + } + if a.toolName != b.toolName { + return false + } + if a.kind == blockToolResult && a.isError != b.isError { + return false + } + if a.args != b.args || a.result != b.result { + return false + } + return true +} + +func hasExpandedToolBlock(expandedBlocks map[int]bool, start, end int) bool { + for i := start; i < end; i++ { + if expandedBlocks[i] { + return true + } + } + return false +} + +func messagesToBlocks(messages []codersdk.ChatMessage) []chatBlock { + blocks := make([]chatBlock, 0) + for _, message := range messages { + if message.Role == codersdk.ChatMessageRoleSystem { + continue + } + for _, part := range message.Content { + switch part.Type { + case codersdk.ChatMessagePartTypeText: + blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: part.Text}) + case codersdk.ChatMessagePartTypeReasoning: + blocks = append(blocks, chatBlock{kind: blockReasoning, role: message.Role, text: part.Text}) + case codersdk.ChatMessagePartTypeToolCall, codersdk.ChatMessagePartTypeToolResult: + block := chatBlock{role: message.Role, toolName: part.ToolName, toolID: part.ToolCallID} + switch { + case part.ToolName == contextCompactionToolName: + block.kind = blockCompaction + case part.Type == codersdk.ChatMessagePartTypeToolCall: + block.kind = blockToolCall + block.args = compactTranscriptJSON(part.Args) + default: + block.kind = blockToolResult + block.result = compactTranscriptJSON(part.Result) + block.isError = part.IsError + } + blocks = append(blocks, block) + case codersdk.ChatMessagePartTypeSource: + title := part.Title + if strings.TrimSpace(title) == "" { + title = part.URL + } + blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)}) + case codersdk.ChatMessagePartTypeFile: + blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[File: %s]", part.MediaType)}) + case codersdk.ChatMessagePartTypeFileReference: + blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)}) + } + } + } + return mergeConsecutiveToolBlocks(blocks) +} + +func mergeToolResult(call, result chatBlock) chatBlock { + if call.toolName != "" { + result.toolName = call.toolName + } + result.kind = blockToolResult + result.toolID = call.toolID + result.args = call.args + return result +} + +func mergeConsecutiveToolBlocks(blocks []chatBlock) []chatBlock { + if len(blocks) < 2 { + return blocks + } + + merged := make([]chatBlock, 0, len(blocks)) + for i := 0; i < len(blocks); i++ { + block := blocks[i] + if i+1 < len(blocks) { + next := blocks[i+1] + if block.kind == blockToolCall && next.kind == blockToolResult { + switch { + case block.toolID != "" && block.toolID == next.toolID: + merged = append(merged, mergeToolResult(block, next)) + i++ + continue + case block.toolID == "" && next.toolID == "" && block.toolName == next.toolName: + merged = append(merged, mergeToolResult(block, next)) + i++ + continue + } + } + } + merged = append(merged, block) + } + return merged +} + +//nolint:revive // Signature keeps block expansion state explicit at the callsite. +func renderBlock(styles tuiStyles, block chatBlock, expanded bool, width int, renderers ...*glamour.TermRenderer) string { + var renderer *glamour.TermRenderer + if len(renderers) > 0 { + renderer = renderers[0] + } + switch block.kind { + case blockText: + switch block.role { + case codersdk.ChatMessageRoleUser: + return renderPrefixedBlock(styles.userMessage.Render("You: "), block.text, width) + case codersdk.ChatMessageRoleAssistant: + return renderAssistantMarkdown(styles, block.text, width, renderer) + case codersdk.ChatMessageRoleTool: + return styles.dimmedText.Render(wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width)) + default: + return wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width) + } + case blockReasoning: + content := wrapPreservingNewlines(reasoningPrefix+sanitizeTerminalRenderableText(block.text), width) + if !expanded { + content = clampLines(content, 3) + } + return styles.reasoning.Render(content) + case blockToolCall: + if !expanded { + return renderToolCallBlock(styles, block, width) + } + return renderExpandedToolBlock(styles, styles.toolPending, pendingToolIcon, block.toolName, block.args, "", width) + case blockToolResult: + if !expanded { + return renderToolResultBlock(styles, block, width) + } + icon := "✓" + labelStyle := styles.toolSuccess + if block.isError { + icon = "✗" + labelStyle = styles.errorText + } + result := block.result + if strings.TrimSpace(result) == "" { + result = "null" + } + return renderExpandedToolBlock(styles, labelStyle, icon, block.toolName, block.args, result, width) + case blockCompaction: + return renderCompaction(styles, width) + default: + return "" + } +} + +var ( + fallbackMarkdownRenderers sync.Map + markdownRendererMu sync.Mutex +) + +func getFallbackMarkdownRenderer(width int) *glamour.TermRenderer { + wrapWidth := contentWidth(width, 0) + if cachedRenderer, ok := fallbackMarkdownRenderers.Load(wrapWidth); ok { + renderer, ok := cachedRenderer.(*glamour.TermRenderer) + if ok { + return renderer + } + } + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(wrapWidth), + ) + if err != nil { + return nil + } + cachedRenderer, _ := fallbackMarkdownRenderers.LoadOrStore(wrapWidth, renderer) + storedRenderer, ok := cachedRenderer.(*glamour.TermRenderer) + if !ok { + return nil + } + return storedRenderer +} + +func renderAssistantMarkdown(styles tuiStyles, text string, width int, renderers ...*glamour.TermRenderer) string { + text = sanitizeTerminalRenderableText(text) + var renderer *glamour.TermRenderer + if len(renderers) > 0 { + renderer = renderers[0] + } + if renderer == nil { + renderer = getFallbackMarkdownRenderer(width) + } + if renderer != nil { + markdownRendererMu.Lock() + rendered, err := renderer.Render(text) + markdownRendererMu.Unlock() + if err == nil { + trimmedRendered := strings.TrimRight(rendered, "\n") + if strings.TrimSpace(trimmedRendered) != "" || strings.TrimSpace(text) == "" { + return styles.assistantMsg.Render(trimmedRendered) + } + } + } + return styles.assistantMsg.Render(wrapPreservingNewlines(text, width)) +} + +func renderPrefixedBlock(prefix, body string, width int) string { + body = sanitizeTerminalRenderableText(body) + if strings.TrimSpace(body) == "" { + return prefix + } + prefixWidth := lipgloss.Width(prefix) + available := width - prefixWidth + if available <= 0 { + available = width + } + wrapped := wrapPreservingNewlines(body, available) + lines := strings.Split(wrapped, "\n") + if len(lines) == 0 { + return prefix + } + for i := 1; i < len(lines); i++ { + lines[i] = strings.Repeat(" ", max(prefixWidth, 0)) + lines[i] + } + return prefix + strings.Join(lines, "\n") +} + +func wrapPreservingNewlines(text string, width int) string { + if width <= 0 { + return text + } + style := lipgloss.NewStyle().Width(width) + segments := strings.Split(text, "\n") + for i, segment := range segments { + segments[i] = strings.TrimRight(style.Render(segment), " ") + } + return strings.Join(segments, "\n") +} + +func clampLines(text string, maxLines int) string { + return strings.Join(clampLineSlice(strings.Split(text, "\n"), maxLines), "\n") +} + +func clampLineSlice(lines []string, maxLines int) []string { + if maxLines <= 0 { + return nil + } + if len(lines) <= maxLines { + return lines + } + clamped := append([]string(nil), lines[:maxLines]...) + clamped[maxLines-1] = stylesafeEllipsis(clamped[maxLines-1]) + return clamped +} + +func stylesafeEllipsis(line string) string { + trimmed := strings.TrimRight(line, " ") + if trimmed == "" { + return "…" + } + return trimmed + "…" +} + +func countRenderedLines(text string) int { + if text == "" { + return 0 + } + return strings.Count(text, "\n") + 1 +} diff --git a/cli/exp_agents_render_test.go b/cli/exp_agents_render_test.go new file mode 100644 index 0000000000..8be00ff220 --- /dev/null +++ b/cli/exp_agents_render_test.go @@ -0,0 +1,864 @@ +package cli //nolint:testpackage // Tests unexported chat TUI render helpers. + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "testing" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func TestExpAgentsRender(t *testing.T) { + t.Parallel() + + styles := newTUIStyles() + + t.Run("MessagesToBlocks", func(t *testing.T) { + t.Parallel() + + user, assistant, tool := codersdk.ChatMessageRoleUser, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool + msg := func(role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { + return codersdk.ChatMessage{Role: role, Content: parts} + } + text := func(body string) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: body} + } + reasoning := func(body string) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: body} + } + call := func(name, id, args string) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolName: name, ToolCallID: id, Args: rawJSON(args)} + } + result := func(name, id, body string, isError bool) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolResult, ToolName: name, ToolCallID: id, Result: rawJSON(body), IsError: isError} + } + + tests := []struct { + name string + in []codersdk.ChatMessage + want []chatBlock + }{ + {name: "EmptyMessages", want: []chatBlock{}}, + {name: "UserText", in: []codersdk.ChatMessage{msg(user, text("hello"))}, want: []chatBlock{{kind: blockText, role: user, text: "hello"}}}, + {name: "AssistantText", in: []codersdk.ChatMessage{msg(assistant, text("hi there"))}, want: []chatBlock{{kind: blockText, role: assistant, text: "hi there"}}}, + {name: "ToolCallPart", in: []codersdk.ChatMessage{msg(assistant, call("weather", "call-1", `{"city":"SF"}`))}, want: []chatBlock{{kind: blockToolCall, role: assistant, toolName: "weather", toolID: "call-1", args: `{"city":"SF"}`}}}, + {name: "ToolResultPart", in: []codersdk.ChatMessage{msg(tool, result("weather", "call-1", `{"temp":"68F"}`, true))}, want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "weather", toolID: "call-1", result: `{"temp":"68F"}`, isError: true}}}, + { + name: "MultipleMessagesInOrder", + in: []codersdk.ChatMessage{ + msg(user, text("question")), + msg(assistant, reasoning("thinking"), call("search", "call-3", `{"q":"docs"}`), text("answer")), + }, + want: []chatBlock{ + {kind: blockText, role: user, text: "question"}, + {kind: blockReasoning, role: assistant, text: "thinking"}, + {kind: blockToolCall, role: assistant, toolName: "search", toolID: "call-3", args: `{"q":"docs"}`}, + {kind: blockText, role: assistant, text: "answer"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, messagesToBlocks(tt.in)) + }) + } + + t.Run("KeepsToolCallsAndLaterResultsSeparateByToolID", func(t *testing.T) { + t.Parallel() + + blocks := messagesToBlocks([]codersdk.ChatMessage{ + msg(assistant, + call("github__get_pull_request", "call-1", `{"owner":"openclaw","repo":"openclaw","pull_number":58036}`), + call("github__get_pull_request", "call-2", `{"owner":"openclaw","repo":"openclaw","pull_number":58037}`), + ), + msg(tool, + result("github__get_pull_request", "call-1", `{"base":{"ref":"main"}}`, false), + result("github__get_pull_request", "call-2", `{"base":{"ref":"main"}}`, false), + ), + }) + + require.Len(t, blocks, 4) + require.Equal(t, + []chatBlockKind{blockToolCall, blockToolCall, blockToolResult, blockToolResult}, + []chatBlockKind{blocks[0].kind, blocks[1].kind, blocks[2].kind, blocks[3].kind}, + ) + require.Equal(t, []string{"call-1", "call-2", "call-1", "call-2"}, []string{blocks[0].toolID, blocks[1].toolID, blocks[2].toolID, blocks[3].toolID}) + }) + }) + + t.Run("MergeConsecutiveToolBlocks", func(t *testing.T) { + t.Parallel() + + assistant, tool := codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool + call := func(name, id, args string) chatBlock { + return chatBlock{kind: blockToolCall, role: assistant, toolName: name, toolID: id, args: args} + } + result := func(name, id, body string) chatBlock { + return chatBlock{kind: blockToolResult, role: tool, toolName: name, toolID: id, result: body} + } + + for _, tt := range []struct { + name string + in []chatBlock + want []chatBlock + }{ + { + name: "MergesAdjacentEmptyToolIDCallAndResult", + in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("read_file", "", `{"content":"hello"}`)}, + want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}}, + }, + { + name: "ExistingToolIDMergeStillWorks", + in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), result("read_file", "call-1", `{"content":"hello"}`)}, + want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}}, + }, + { + name: "MultiplePairs", + in: []chatBlock{ + call("read_file", "call-1", `{"path":"one.txt"}`), + result("read_file", "call-1", `{"ok":true}`), + call("list_dir", "call-2", `{"path":"/tmp"}`), + result("list_dir", "call-2", `{"entries":[]}`), + }, + want: []chatBlock{ + {kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"one.txt"}`, result: `{"ok":true}`}, + {kind: blockToolResult, role: tool, toolName: "list_dir", toolID: "call-2", args: `{"path":"/tmp"}`, result: `{"entries":[]}`}, + }, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mergeConsecutiveToolBlocks(tt.in) + require.Equal(t, tt.want, got) + }) + } + + t.Run("NegativeMergeCases", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + in []chatBlock + want []chatBlock + }{ + { + name: "DifferentToolNames", + in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)}, + want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)}, + }, + { + name: "NonAdjacentEmptyToolID", + in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)}, + want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)}, + }, + { + name: "NonAdjacentMatchingToolID", + in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)}, + want: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)}, + }, + { + name: "OrphanedCall", + in: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)}, + want: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)}, + }, + { + name: "OrphanedResult", + in: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)}, + want: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)}, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mergeConsecutiveToolBlocks(tt.in) + require.Equal(t, tt.want, got) + }) + } + }) + }) + + t.Run("ToolArgsSummary", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + toolName string + args string + assert func(t *testing.T, summary string) + }{ + {name: "CreateWorkspaceUsesNameField", toolName: "coder_create_workspace", args: `{"name":"my-workspace"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-workspace)", summary) }}, + {name: "CreateWorkspaceUsesWorkspaceNameField", toolName: "coder_create_workspace", args: `{"workspace_name":"my-ws","template":"docker"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-ws)", summary) }}, + {name: "WithUnicodeTruncatesOnRuneBoundary", toolName: "weather", args: strings.Repeat("こんにちは世界", 10), assert: func(t *testing.T, summary string) { + require.NotEmpty(t, summary) + require.True(t, utf8.ValidString(summary)) + require.True(t, strings.HasSuffix(summary, "…")) + require.LessOrEqual(t, len([]rune(summary)), toolSummaryFallbackWidth) + require.Contains(t, summary, "こんにちは") + }}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.assert(t, toolArgsSummary(tt.toolName, tt.args)) + }) + } + require.Equal(t, "(created-ws)", toolResultSummary("coder_create_workspace", "", `{"workspace_name":"created-ws"}`)) + }) + t.Run("RenderToolCall", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + part codersdk.ChatMessagePart + width int + assert func(t *testing.T, output string) + }{ + {name: "ShowsHumanizedToolNameAndContext", part: codersdk.ChatMessagePart{ToolName: "github__get_pull_request", Args: rawJSON(`{"owner":"openclaw","repo":"openclaw","pull_number":58036}`)}, width: 60, assert: func(t *testing.T, output string) { + require.Contains(t, output, " ○ get pull request") + require.Contains(t, output, "(openclaw/openclaw)") + }}, + {name: "ShowsTruncatedCommandPreview", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la /tmp/with/a/very/long/path"}`)}, width: 30, assert: func(t *testing.T, output string) { + require.Contains(t, output, "○ execute command") + require.Contains(t, output, `"ls -la`) + require.Contains(t, output, "…") + }}, + {name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, output string) { + require.Contains(t, output, "🗜️ Context compacted") + require.NotContains(t, output, pendingToolIcon) + }}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output string + require.NotPanics(t, func() { + output = plainText(renderToolCallBlock(styles, chatBlock{ + kind: blockToolCall, + toolName: tt.part.ToolName, + args: compactTranscriptJSON(tt.part.Args), + }, tt.width)) + }) + tt.assert(t, output) + }) + } + }) + t.Run("RenderToolResult", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + part codersdk.ChatMessagePart + width int + assert func(t *testing.T, rawOutput, plainOutput string) + }{ + {name: "SuccessShowsCheckPrefixAndArgsContext", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la"}`), Result: rawJSON(`{"ok":true}`)}, width: 40, assert: func(t *testing.T, _, output string) { + require.Contains(t, output, "✓ execute command") + require.Contains(t, output, `"ls -la"`) + }}, + {name: "ErrorShowsErrorStyleAndMessage", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Result: rawJSON(`{"error":"command not found"}`), IsError: true}, width: 40, assert: func(t *testing.T, rawOutput, plainOutput string) { + require.Contains(t, rawOutput, styles.errorText.Render("✗ execute command")) + require.Contains(t, plainOutput, `"command not found"`) + }}, + {name: "MergedCreateWorkspaceResultKeepsArgsSummary", part: codersdk.ChatMessagePart{ToolName: "coder_create_workspace", ToolCallID: "call-create-workspace", Args: rawJSON(`{"name":"merged-workspace"}`), Result: rawJSON(`{"workspace_name":"merged-workspace","status":"created"}`)}, width: 60, assert: func(t *testing.T, _, output string) { + require.Contains(t, output, "✓ create workspace") + require.Contains(t, output, "(merged-workspace)") + }}, + {name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, _, output string) { + require.Contains(t, output, "🗜️ Context compacted") + require.NotContains(t, output, "✓") + }}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var rawOutput string + require.NotPanics(t, func() { + rawOutput = renderToolResultBlock(styles, chatBlock{ + kind: blockToolResult, + toolName: tt.part.ToolName, + args: compactTranscriptJSON(tt.part.Args), + result: compactTranscriptJSON(tt.part.Result), + isError: tt.part.IsError, + }, tt.width) + }) + tt.assert(t, rawOutput, plainText(rawOutput)) + }) + } + }) + t.Run("RenderCompaction", func(t *testing.T) { + t.Parallel() + + output := plainText(renderCompaction(styles, 20)) + require.Contains(t, output, "🗜️ Context compacted") + }) + t.Run("RenderStatusBar", func(t *testing.T) { + t.Parallel() + + u := func(total, limit int64) *codersdk.ChatMessageUsage { + return &codersdk.ChatMessageUsage{TotalTokens: int64Ptr(total), ContextLimit: int64Ptr(limit)} + } + + for _, tt := range []struct { + name string + status codersdk.ChatStatus + usage *codersdk.ChatMessageUsage + queue int + interrupting, reconnecting bool + width, maxWidth int + wantRaw string + wantPlain, avoidPlain []string + }{ + {name: "RunningOmitsUsageWhenNil", status: codersdk.ChatStatusRunning, width: 80, avoidPlain: []string{"tokens:"}}, + {name: "RunningShowsTokenUsage", status: codersdk.ChatStatusRunning, usage: u(50, 100), width: 80, wantPlain: []string{"tokens: 50/100"}}, + {name: "RunningWarnsAndShowsTransientStates", status: codersdk.ChatStatusRunning, usage: u(81, 100), interrupting: true, reconnecting: true, width: 80, wantRaw: styles.warningText.Render("tokens: 81/100"), wantPlain: []string{"interrupting…", "reconnecting…"}}, + {name: "RunningShowsCriticalUsage", status: codersdk.ChatStatusRunning, usage: u(96, 100), width: 80, wantRaw: styles.criticalText.Render("tokens: 96/100")}, + {name: "PendingShowsQueue", status: codersdk.ChatStatusPending, queue: 2, width: 80, wantPlain: []string{"queued: 2"}}, + {name: "NarrowWidthFits", status: codersdk.ChatStatusRunning, usage: u(96, 100), queue: 2, interrupting: true, reconnecting: true, width: 20, maxWidth: 20}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var output string + require.NotPanics(t, func() { + output = renderStatusBar(styles, nil, tt.status, tt.usage, tt.queue, tt.interrupting, tt.reconnecting, tt.width) + }) + plain := plainText(output) + require.Contains(t, output, styles.statusColor(tt.status).Render(string(tt.status))) + if tt.wantRaw != "" { + require.Contains(t, output, tt.wantRaw) + } + for _, want := range tt.wantPlain { + require.Contains(t, plain, want) + } + for _, avoid := range tt.avoidPlain { + require.NotContains(t, plain, avoid) + } + if tt.maxWidth > 0 { + require.NotEmpty(t, plain) + require.LessOrEqual(t, lipgloss.Width(plain), tt.maxWidth) + require.LessOrEqual(t, lipgloss.Width(output), tt.width) + } + }) + } + }) + t.Run("RenderBlock", func(t *testing.T) { + t.Parallel() + + renderOutput := func(block chatBlock, expanded, plain bool, width int) string { + output := renderBlock(styles, block, expanded, width) + if plain { + return plainText(output) + } + return output + } + assertOutput := func(t *testing.T, output string, want, avoid []string, lines int, lastLine string) { + t.Helper() + for _, s := range want { + require.Contains(t, output, s) + } + for _, s := range avoid { + require.NotContains(t, output, s) + } + if lines > 0 { + split := strings.Split(output, "\n") + require.Len(t, split, lines) + if lastLine != "" { + require.Equal(t, lastLine, strings.TrimRight(split[len(split)-1], " ")) + } + } + } + + for _, tt := range []struct { + name string + block chatBlock + want []string + avoid []string + }{ + {name: "UserIncludesYouPrefix", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"}, want: []string{"You: hello"}}, + {name: "AssistantRendersMarkdown", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "- first\n- second"}, want: []string{"• first", "• second"}, avoid: []string{"- first"}}, + {name: "ToolRendersDimmed", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleTool, text: "tool output"}, want: []string{styles.dimmedText.Render("tool output")}}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assertOutput(t, renderOutput(tt.block, false, tt.block.role != codersdk.ChatMessageRoleTool, 40), tt.want, tt.avoid, 0, "") + }) + } + + for _, tt := range []struct { + name string + block chatBlock + width int + collapsedWant []string + collapsedAvoid []string + collapsedLines int + collapsedLastLine string + expandedWant []string + expandedAvoid []string + expandedLines int + expandedLastLine string + }{ + { + name: "Reasoning", + block: chatBlock{kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "line1\nline2\nline3\nline4"}, + width: 40, + collapsedWant: []string{"thinking: line1"}, + collapsedLines: 3, + collapsedLastLine: "line3…", + expandedWant: []string{"line4"}, + expandedAvoid: []string{"line4…"}, + expandedLines: 4, + }, + { + name: "ToolCall", + block: chatBlock{kind: blockToolCall, toolName: "read_file", args: `{"path":"very/long/path.txt","recursive":true}`}, + width: 60, + collapsedWant: []string{"○ read file", "(very/long/path.txt)"}, + collapsedAvoid: []string{"\n", "args:"}, + expandedWant: []string{"○ read file", "args:", `{"path":"very/long/path.txt","recursive":true}`, "\n"}, + }, + { + name: "ToolResult", + block: chatBlock{kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`}, + width: 60, + collapsedWant: []string{"✓ read file", "(a.txt)"}, + collapsedAvoid: []string{"\n", "result:"}, + expandedWant: []string{"✓ read file", "args:", "result:", `{"path":"a.txt","contents":"hello"}`, "\n"}, + }, + { + name: "CollapsedToolCallShowsRunCount", + block: chatBlock{kind: blockToolCall, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, collapsedCount: 3}, + width: 80, + collapsedWant: []string{"○ get pull request..."}, + }, + { + name: "CollapsedToolResultShowsRunCount", + block: chatBlock{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, result: `{"ok":true}`, collapsedCount: 10}, + width: 80, + collapsedWant: []string{"✓ get pull request (x10)"}, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + collapsed := renderOutput(tt.block, false, true, tt.width) + assertOutput(t, collapsed, tt.collapsedWant, tt.collapsedAvoid, tt.collapsedLines, tt.collapsedLastLine) + if len(tt.expandedWant)+len(tt.expandedAvoid)+tt.expandedLines > 0 || tt.expandedLastLine != "" { + expanded := renderOutput(tt.block, true, true, tt.width) + assertOutput(t, expanded, tt.expandedWant, tt.expandedAvoid, tt.expandedLines, tt.expandedLastLine) + } + }) + } + + t.Run("CompactionRendersBanner", func(t *testing.T) { + t.Parallel() + + output := plainText(renderBlock(styles, chatBlock{kind: blockCompaction}, false, 40)) + require.Contains(t, output, "🗜️ Context compacted") + }) + }) + + t.Run("RenderChatBlocks", func(t *testing.T) { + t.Parallel() + + t.Run("MixedMessagesRenderInOrder", func(t *testing.T) { + t.Parallel() + + blocks := []chatBlock{ + {kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"}, + {kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "thinking"}, + {kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`}, + {kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "done"}, + } + + output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 60)) + require.Contains(t, output, "You: hello") + require.Contains(t, output, "thinking: thinking") + require.Contains(t, output, "✓ read file") + require.Contains(t, output, "done") + require.Less(t, strings.Index(output, "You: hello"), strings.Index(output, "thinking: thinking")) + require.Less(t, strings.Index(output, "thinking: thinking"), strings.Index(output, "✓ read file")) + require.Less(t, strings.Index(output, "✓ read file"), strings.LastIndex(output, "done")) + }) + + t.Run("SelectedBlockUsesLeftBorderIndicator", func(t *testing.T) { + t.Parallel() + + blocks := []chatBlock{{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"}} + + output := plainText(renderChatBlocks(styles, blocks, 0, map[int]bool{}, false, 60)) + require.Contains(t, output, "│ assistant reply") + }) + + t.Run("CollapsesConsecutiveSameNameToolResults", func(t *testing.T) { + t.Parallel() + + blocks := []chatBlock{ + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`}, + } + + output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80)) + require.Equal(t, 2, strings.Count(output, "✓")) + require.Contains(t, output, "get pull request (x3)") + require.Contains(t, output, "create file") + }) + + t.Run("DoesNotCollapseDifferentToolResults", func(t *testing.T) { + t.Parallel() + + blocks := []chatBlock{ + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":2}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":3}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`}, + } + + output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80)) + require.Equal(t, 4, strings.Count(output, "✓")) + require.NotContains(t, output, "get pull request (x3)") + require.Contains(t, output, "create file") + }) + + t.Run("ExpandedToolBlockPreventsCollapse", func(t *testing.T) { + t.Parallel() + + blocks := []chatBlock{ + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + {kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`}, + } + + output := plainText(renderChatBlocks(styles, blocks, 1, map[int]bool{1: true}, false, 80)) + require.Equal(t, 2, strings.Count(output, "✓")) + require.NotContains(t, output, "(x2)") + require.Contains(t, output, "result:") + }) + }) + t.Run("RenderDiffDrawer", func(t *testing.T) { + t.Parallel() + + branch := "feature/chat-ui" + prURL := "https://example.com/pulls/123" + for _, tt := range []struct { + name string + diff codersdk.ChatDiffContents + changes []codersdk.ChatGitChange + assert func(t *testing.T, output string) + }{ + {name: "ShowsMetadataWhenPresent", diff: codersdk.ChatDiffContents{Branch: &branch, PullRequestURL: &prURL}, assert: func(t *testing.T, output string) { + require.Contains(t, output, "Branch: feature/chat-ui") + require.Contains(t, output, "PR: https://example.com/pulls/123") + }}, + {name: "ShowsDiffContent", diff: codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+added line"}, changes: []codersdk.ChatGitChange{{FilePath: "a.txt", ChangeType: "modified"}}, assert: func(t *testing.T, output string) { + require.Contains(t, output, "diff --git a/a.txt b/a.txt") + require.Contains(t, output, "+added line") + }}, + {name: "ShowsPlaceholderForEmptyDiff", assert: func(t *testing.T, output string) { require.Contains(t, output, "No diff contents.") }}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output string + require.NotPanics(t, func() { output = plainText(renderDiffDrawer(styles, tt.diff, tt.changes, 90, 20)) }) + tt.assert(t, output) + }) + } + }) + t.Run("RenderDiffDrawerSanitizesUntrustedContent", func(t *testing.T) { + t.Parallel() + + rawOutput := renderDiffDrawer( + styles, + codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+safe\x1b]52;c;clipboard\x07line"}, + []codersdk.ChatGitChange{{ + FilePath: "a.txt\x1b]52;c;clipboard\x07", + ChangeType: "modified", + }}, + 90, + 20, + ) + output := plainText(rawOutput) + + require.Contains(t, output, "diff --git a/a.txt b/a.txt") + require.Contains(t, output, "+safeline") + require.Contains(t, output, "modified a.txt") + require.NotContains(t, rawOutput, "clipboard") + require.NotContains(t, rawOutput, "\x1b]52") + }) + t.Run("RenderModelPicker", func(t *testing.T) { + t.Parallel() + + catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ + Provider: "OpenAI", + Available: true, + Models: []codersdk.ChatModel{{ID: "gpt-4o", Provider: "OpenAI", Model: "gpt-4o", DisplayName: "GPT-4o"}, {ID: "gpt-4.1", Provider: "OpenAI", Model: "gpt-4.1", DisplayName: "GPT-4.1"}}, + }, { + Provider: "Anthropic", + Available: false, + UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey, + }, { + Provider: "Local", + Available: true, + Models: nil, + }}} + for _, tt := range []struct { + name string + selectedModel string + selectedIndex int + assert func(t *testing.T, output string) + }{ + {name: "GroupsModelsByProvider", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) { + require.Contains(t, output, "OpenAI") + require.Contains(t, output, "GPT-4o") + require.Contains(t, output, "GPT-4.1") + }}, + {name: "ShowsCursorIndicatorOnSelectedPosition", selectedModel: "gpt-4.1", selectedIndex: 1, assert: func(t *testing.T, output string) { + require.Contains(t, output, "> GPT-4.1") + require.Contains(t, output, " GPT-4o") + }}, + {name: "HidesProvidersWithoutModels", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) { + require.Contains(t, output, "OpenAI") + require.NotContains(t, output, "Anthropic") + require.NotContains(t, output, "Local") + }}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output string + require.NotPanics(t, func() { + output = plainText(renderModelPicker(styles, catalog, tt.selectedModel, tt.selectedIndex, 90, 20)) + }) + tt.assert(t, output) + }) + } + + t.Run("ShowsGlobalEmptyStateWhenNoModelsSelectable", func(t *testing.T) { + t.Parallel() + + emptyCatalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ + Provider: "Anthropic", + Available: false, + UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey, + }, { + Provider: "Local", + Available: true, + Models: nil, + }}} + + output := plainText(renderModelPicker(styles, emptyCatalog, "", 0, 90, 20)) + require.NotContains(t, output, "Anthropic") + require.NotContains(t, output, "Local") + require.Equal(t, 1, strings.Count(output, "No models available.")) + }) + }) + t.Run("KeepsCursorVisibleWithinWindow", func(t *testing.T) { + t.Parallel() + + models := make([]codersdk.ChatModel, 0, 6) + for i := 1; i <= 6; i++ { + models = append(models, codersdk.ChatModel{ + ID: fmt.Sprintf("provider:model-%d", i), + Provider: "provider", + Model: fmt.Sprintf("model-%d", i), + DisplayName: fmt.Sprintf("Model %d", i), + }) + } + catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: models, + }}} + + output := plainText(renderModelPicker(styles, catalog, "provider:model-5", 4, 60, 8)) + require.Contains(t, output, "> Model 5") + require.NotContains(t, output, "Model 1") + }) + + t.Run("RenderAssistantMarkdown", func(t *testing.T) { + t.Parallel() + + output := plainText(renderAssistantMarkdown(styles, "- first\n- second", 60, nil)) + require.Contains(t, output, "• first") + require.Contains(t, output, "• second") + require.NotContains(t, output, "- first") + }) + + t.Run("SanitizeTerminalRenderableText", func(t *testing.T) { + t.Parallel() + + output := sanitizeTerminalRenderableText("safe\ttext\n\x1b[31mred\u009b32mgreen\x1b]52;c;clipboard\x07\x1b(Bdone\r\x00") + require.Equal(t, "safe\ttext\nredgreendone", output) + require.NotContains(t, output, "\x1b") + require.NotContains(t, output, "\x07") + require.NotContains(t, output, "\r") + require.NotContains(t, output, "\x00") + }) + + t.Run("RenderToolDetailStripsTerminalEscapes", func(t *testing.T) { + t.Parallel() + + rawOutput := renderToolDetail(styles, "result", "ok\x1b]52;c;clipboard\x07\n\tstill here", 60) + output := plainText(rawOutput) + require.Contains(t, output, "result: ok") + require.Contains(t, output, "still here") + require.NotContains(t, output, "clipboard") + require.NotContains(t, output, "\x1b") + require.NotContains(t, output, "\x07") + }) + t.Run("UtilityRenderers", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct{ name, input, want string }{ + {name: "WrapPreservingNewlines/PreservesExplicitNewlines", input: "line one\nline two", want: "line one\nline two"}, + {name: "WrapPreservingNewlines/EmptyString", input: "", want: ""}, + {name: "WrapPreservingNewlines/OnlyNewlines", input: "\n\n\n", want: "\n\n\n"}, + } { + require.Equalf(t, tt.want, wrapPreservingNewlines(tt.input, 40), tt.name) + } + for _, tt := range []struct { + name string + input string + max int + assert func(t *testing.T, output string) + }{ + {name: "ClampLines/AddsEllipsis", input: "line1\nline2\nline3\nline4", max: 3, assert: func(t *testing.T, output string) { + lines := strings.Split(output, "\n") + require.Len(t, lines, 3) + require.Equal(t, "line3…", lines[2]) + }}, + {name: "ClampLines/ZeroMax", input: "line1\nline2", max: 0, assert: func(t *testing.T, output string) { require.Empty(t, output) }}, + } { + tt.assert(t, clampLines(tt.input, tt.max)) + } + for _, tt := range []struct { + name string + prefix string + input string + width int + assert func(t *testing.T, output string) + }{ + {name: "RenderPrefixedBlock/IndentsContinuationLines", prefix: "You: ", input: "alpha beta gamma delta", width: 12, assert: func(t *testing.T, output string) { + lines := strings.Split(output, "\n") + require.GreaterOrEqual(t, len(lines), 2) + require.True(t, strings.HasPrefix(lines[1], strings.Repeat(" ", lipgloss.Width("You: ")))) + require.Contains(t, output, "You: ") + }}, + {name: "RenderPrefixedBlock/EmptyContent", prefix: "You: ", width: 12, assert: func(t *testing.T, output string) { require.Equal(t, "You: ", output) }}, + } { + tt.assert(t, renderPrefixedBlock(tt.prefix, tt.input, tt.width)) + } + }) + + t.Run("RenderAskUserQuestion", func(t *testing.T) { + t.Parallel() + + firstQuestion := parsedAskQuestion{ + Header: "Review plan", + Question: "Which plan should we use?", + Options: []parsedAskOption{ + {Label: "Fast path", Value: "fast"}, + {Label: "Safe path", Value: "safe"}, + }, + } + secondQuestion := parsedAskQuestion{ + Header: "Risk", + Question: "How much risk is acceptable?", + Options: []parsedAskOption{{Label: "Low", Value: "low"}}, + } + renderPlain := func(state *askUserQuestionState, width, height int) string { + return plainText(renderAskUserQuestion(styles, state, width, height)) + } + + t.Run("BasicRenderShowsQuestionOptionsAndHelp", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + output := renderPlain(state, 100, 20) + + require.Contains(t, output, "Plan Question 1/1") + require.Contains(t, output, firstQuestion.Header) + require.Contains(t, output, firstQuestion.Question) + require.Contains(t, output, "Fast path") + require.Contains(t, output, "Safe path") + require.Contains(t, output, "Other (type custom answer)") + require.Contains(t, output, "↑/↓ navigate") + require.Contains(t, output, "enter select") + }) + + t.Run("SelectedOptionShowsCursor", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + state.OptionCursor = 1 + output := renderPlain(state, 100, 20) + + require.Contains(t, output, "> Safe path") + require.NotContains(t, output, "> Fast path") + }) + + t.Run("MultipleQuestionsShowProgress", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion, firstQuestion}) + state.CurrentIndex = 1 + output := renderPlain(state, 100, 20) + + require.Contains(t, output, "Plan Question 2/3") + require.Contains(t, output, secondQuestion.Header) + require.Contains(t, output, secondQuestion.Question) + }) + + t.Run("FreeformInputIsVisible", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + state.OptionCursor = len(firstQuestion.Options) + state.OtherMode = true + state.OtherInput.Focus() + state.OtherInput.SetValue("Need a custom plan") + output := renderPlain(state, 100, 20) + + require.Contains(t, output, "Need a custom plan") + require.Contains(t, output, "esc cancel input") + }) + + t.Run("NarrowTerminalDoesNotPanic", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + var output string + require.NotPanics(t, func() { + output = renderPlain(state, 18, 6) + }) + require.NotEmpty(t, strings.TrimSpace(output)) + }) + }) +} + +func plainText(text string) string { + return ansiRegexp.ReplaceAllString(text, "") +} + +func rawJSON(value string) json.RawMessage { + return json.RawMessage([]byte(value)) +} + +func int64Ptr(value int64) *int64 { + return &value +} diff --git a/cli/exp_agents_stream_test.go b/cli/exp_agents_stream_test.go new file mode 100644 index 0000000000..95826db474 --- /dev/null +++ b/cli/exp_agents_stream_test.go @@ -0,0 +1,131 @@ +package cli //nolint:testpackage // Tests unexported chat stream helpers. + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +type chatWatchWriters struct{ stdout, stderr io.Writer } + +func (w chatWatchWriters) Write(p []byte) (int, error) { return w.stdout.Write(p) } + +func (w chatWatchWriters) Stderr() io.Writer { + if w.stderr != nil { + return w.stderr + } + return w.stdout +} + +func consumeChatStream(eventCh <-chan codersdk.ChatStreamEvent, out io.Writer) error { + errOut := out + if writer, ok := out.(interface{ Stderr() io.Writer }); ok { + errOut = writer.Stderr() + } + + printedInline := false + flush := func() error { + if !printedInline { + return nil + } + printedInline = false + _, err := fmt.Fprintln(out) + return err + } + + printLine := func(dst io.Writer, format string, args ...any) error { + if err := flush(); err != nil { + return err + } + _, err := fmt.Fprintf(dst, format, args...) + return err + } + + for event := range eventCh { + var err error + switch event.Type { + case codersdk.ChatStreamEventTypeMessagePart: + if part := event.MessagePart; part != nil && + part.Part.Type == codersdk.ChatMessagePartTypeText && part.Part.Text != "" { + printedInline = true + _, err = fmt.Fprint(out, part.Part.Text) + } + case codersdk.ChatStreamEventTypeMessage: + if message := event.Message; message != nil && !printedInline { + for _, part := range message.Content { + if part.Type != codersdk.ChatMessagePartTypeText || part.Text == "" { + continue + } + printedInline = true + if _, err = fmt.Fprint(out, part.Text); err != nil { + break + } + } + } + if err == nil { + err = flush() + } + case codersdk.ChatStreamEventTypeStatus: + if event.Status == nil { + err = flush() + break + } + err = printLine(out, "[Status: %s]\n", event.Status.Status) + case codersdk.ChatStreamEventTypeError: + if event.Error == nil { + err = flush() + break + } + err = printLine(errOut, "[Error: %s]\n", event.Error.Message) + case codersdk.ChatStreamEventTypeRetry: + if event.Retry == nil { + err = flush() + break + } + err = printLine(out, "[Retry attempt %d after error: %s]\n", event.Retry.Attempt, event.Retry.Error) + case codersdk.ChatStreamEventTypeQueueUpdate: + default: + err = printLine(out, "[Event: %s]\n", event.Type) + } + if err != nil { + return xerrors.Errorf("render chat stream event: %w", err) + } + } + + if err := flush(); err != nil { + return xerrors.Errorf("flush chat stream output: %w", err) + } + return nil +} + +func TestConsumeChatStreamText(t *testing.T) { + t.Parallel() + + events := make(chan codersdk.ChatStreamEvent, 7) + for _, event := range []codersdk.ChatStreamEvent{ + {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "Hello"}}}, + {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, Text: "ignored"}}}, + {Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: " world"}}}, + {Type: codersdk.ChatStreamEventTypeMessage, Message: &codersdk.ChatMessage{ID: 1, ChatID: uuid.New(), Role: codersdk.ChatMessageRoleAssistant, Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "Hello world"}}}}, + {Type: codersdk.ChatStreamEventTypeStatus, Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}}, + {Type: codersdk.ChatStreamEventTypeRetry, Retry: &codersdk.ChatStreamRetry{Attempt: 2, Error: "rate limited"}}, + {Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatStreamError{Message: "boom"}}, + } { + events <- event + } + close(events) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := consumeChatStream(events, chatWatchWriters{stdout: &stdout, stderr: &stderr}) + require.NoError(t, err) + require.Equal(t, "Hello world\n[Status: running]\n[Retry attempt 2 after error: rate limited]\n", stdout.String()) + require.Equal(t, "[Error: boom]\n", stderr.String()) +} diff --git a/cli/exp_agents_styles.go b/cli/exp_agents_styles.go new file mode 100644 index 0000000000..28c83f9658 --- /dev/null +++ b/cli/exp_agents_styles.go @@ -0,0 +1,98 @@ +package cli + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/coder/coder/v2/codersdk" +) + +type tuiStyles struct { + title lipgloss.Style + subtitle lipgloss.Style + statusBar lipgloss.Style + statusBadge lipgloss.Style + selectedItem lipgloss.Style + selectedBlock lipgloss.Style + normalItem lipgloss.Style + dimmedText lipgloss.Style + errorText lipgloss.Style + searchInput lipgloss.Style + separator lipgloss.Style + helpText lipgloss.Style + modeBadgeExec lipgloss.Style + modeBadgePlan lipgloss.Style + userMessage lipgloss.Style + assistantMsg lipgloss.Style + reasoning lipgloss.Style + toolCallStyle lipgloss.Style + toolPending lipgloss.Style + toolSuccess lipgloss.Style + compaction lipgloss.Style + warningText lipgloss.Style + criticalText lipgloss.Style + overlayBorder lipgloss.Style + composerStyle lipgloss.Style +} + +func newTUIStyles(renderers ...*lipgloss.Renderer) tuiStyles { + renderer := lipgloss.DefaultRenderer() + if len(renderers) > 0 && renderers[0] != nil { + renderer = renderers[0] + } + + return tuiStyles{ + title: renderer.NewStyle().Bold(true), + subtitle: renderer.NewStyle().Faint(true), + statusBar: renderer.NewStyle(), + statusBadge: renderer.NewStyle().Padding(0, 1), + selectedItem: renderer.NewStyle().Bold(true), + selectedBlock: renderer.NewStyle(). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.AdaptiveColor{Light: "63", Dark: "63"}). + PaddingLeft(1), + normalItem: renderer.NewStyle(), + dimmedText: renderer.NewStyle().Faint(true), + errorText: renderer.NewStyle().Foreground(lipgloss.Color("1")), + searchInput: renderer.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true), + separator: renderer.NewStyle().Faint(true), + helpText: renderer.NewStyle().Faint(true), + modeBadgeExec: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "42"}), + modeBadgePlan: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "214"}), + userMessage: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("6")), + assistantMsg: renderer.NewStyle(), + reasoning: renderer.NewStyle().Faint(true).Italic(true), + toolCallStyle: renderer.NewStyle().Foreground(lipgloss.Color("3")), + toolPending: renderer.NewStyle().Faint(true).Foreground(lipgloss.Color("3")), + toolSuccess: renderer.NewStyle().Foreground(lipgloss.Color("2")), + compaction: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("5")), + warningText: renderer.NewStyle().Foreground(lipgloss.Color("3")), + criticalText: renderer.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), + overlayBorder: renderer.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(1), + composerStyle: renderer.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true), + } +} + +func (s tuiStyles) statusColor(status codersdk.ChatStatus) lipgloss.Style { + color := lipgloss.Color("7") + switch status { + case codersdk.ChatStatusWaiting, codersdk.ChatStatusPending: + color = lipgloss.Color("3") + case codersdk.ChatStatusRunning: + color = lipgloss.Color("4") + case codersdk.ChatStatusPaused: + color = lipgloss.Color("5") + case codersdk.ChatStatusCompleted: + color = lipgloss.Color("2") + case codersdk.ChatStatusError: + color = lipgloss.Color("1") + } + return s.statusBadge.Foreground(color) +} + +func (s tuiStyles) truncate(text string, maxWidth int) string { + _ = s + return truncateText(text, maxWidth, "", 3) +} diff --git a/cli/exp_agents_test.go b/cli/exp_agents_test.go new file mode 100644 index 0000000000..121151ec0a --- /dev/null +++ b/cli/exp_agents_test.go @@ -0,0 +1,3297 @@ +package cli //nolint:testpackage // Tests unexported chat TUI reducers. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/websocket" +) + +func TestExpAgents(t *testing.T) { + t.Parallel() + t.Run("ResolveModel", func(t *testing.T) { + t.Parallel() + catalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "openai", + Available: true, + Models: []codersdk.ChatModel{{ + ID: "openai:gpt-4o", + Provider: "openai", + Model: "gpt-4o", + DisplayName: "GPT-4o", + }}, + }}, + } + + client := newTestExperimentalClient(t, func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(catalog) + }) + tests := []struct { + name string + input string + want string + }{ + {name: "ExactID", input: "openai:gpt-4o", want: "openai:gpt-4o"}, + {name: "ProviderModel", input: "openai/gpt-4o", want: "openai:gpt-4o"}, + {name: "DisplayName", input: "GPT-4o", want: "openai:gpt-4o"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved, err := resolveModel(context.Background(), client, tt.input) + require.NoError(t, err) + require.NotNil(t, resolved) + require.Equal(t, tt.want, *resolved) + }) + } + }) + + t.Run("TopLevelModelRouting", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + overlay tuiOverlay + }{ + {"ModelPicker", overlayModelPicker}, + {"DiffDrawer", overlayDiffDrawer}, + } + for _, tt := range tests { + t.Run("EscFromOverlayClosesIt/"+tt.name, func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = tt.overlay + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, overlayNone, updated.overlay) + }) + } + + t.Run("AdditionalOverlayCloseKeys", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + overlay tuiOverlay + key tea.KeyMsg + }{ + {name: "ModelPicker/KeyEscape", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyEscape}}, + {name: "ModelPicker/CtrlOpenBracket", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}}, + {name: "DiffDrawer/KeyEscape", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyEscape}}, + {name: "DiffDrawer/CtrlOpenBracket", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = tt.overlay + + updatedModel, cmd := model.Update(tt.key) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, overlayNone, updated.overlay) + }) + } + }) + + t.Run("EscFromChatViewReturnsToListAndRefreshes", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = overlayNone + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.True(t, updated.list.loading) + require.NotNil(t, cmd) + }) + + t.Run("EscFromChatViewAdvancesGeneration", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = overlayNone + model.chatGeneration = 4 + model.chat.chatGeneration = 4 + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, uint64(5), updated.chatGeneration) + require.Equal(t, uint64(5), updated.chat.chatGeneration) + require.True(t, updated.chat.matchesGeneration(updated.chatGeneration)) + require.NotNil(t, cmd) + }) + + t.Run("EscFromChatViewRejectsLateChatLoadMessages", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = overlayNone + model.chatGeneration = 4 + model.chat.chatGeneration = 4 + model.chat.chat = &codersdk.Chat{ID: uuid.New(), Title: "current chat"} + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.NotNil(t, cmd) + + staleChat := codersdk.Chat{ID: uuid.New(), Title: "stale chat"} + updatedModel, cmd = updated.Update(chatOpenedMsg{generation: 4, chatID: staleChat.ID, chat: staleChat}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, "current chat", updated.chat.chat.Title) + + staleMessages := []codersdk.ChatMessage{testMessage( + 1, + codersdk.ChatMessageRoleUser, + codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale"}, + )} + updatedModel, cmd = updated.Update(chatHistoryMsg{generation: 4, chatID: staleChat.ID, messages: staleMessages}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Empty(t, updated.chat.messages) + }) + + t.Run("EscFromSearchClearsFilterAndRestoresListNavigation", func(t *testing.T) { + t.Parallel() + chats := []codersdk.Chat{ + {ID: uuid.New(), Title: "alpha", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: uuid.New(), Title: "beta", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: uuid.New(), Title: "gamma", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10}) + model = mustTUIModel(t, updatedModel, cmd) + model.currentView = viewList + model.list.loading = false + model.list.chats = chats + + updatedModel, cmd = model.Update(keyRunes("/")) + updated := mustTUIModel(t, updatedModel, cmd) + require.True(t, updated.list.searching) + + updatedModel, cmd = updated.Update(keyRunes("b")) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, "b", updated.list.search.Value()) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated = mustTUIModel(t, updatedModel, cmd) + require.False(t, updated.quitting) + require.False(t, updated.list.searching) + require.Empty(t, updated.list.search.Value()) + + updatedModel, cmd = updated.Update(keyRunes("j")) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, 1, updated.list.cursor) + + updatedModel, cmd = updated.Update(keyRunes("k")) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, 0, updated.list.cursor) + }) + + for name, view := range map[string]tuiView{ + "List": viewList, + "Chat": viewChat, + } { + t.Run("CtrlCQuitsFromAnyState/"+name, func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = view + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.True(t, updated.quitting) + _, ok := mustMsg(t, cmd).(tea.QuitMsg) + require.True(t, ok) + }) + } + + t.Run("OpenChatSwitchesView", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + msg tea.Msg + draft bool + wantLoading bool + wantBatchLen int + }{ + {name: "SelectedChat", msg: openSelectedChatMsg{chatID: uuid.New()}, wantLoading: true, wantBatchLen: 3}, + {name: "DraftChat", msg: openDraftChatMsg{}, draft: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.width, model.height = 100, 40 + updatedModel, cmd := model.Update(tt.msg) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, 40, updated.chat.height) + require.Equal(t, 34, updated.chat.viewport.Height) + if tt.draft { + require.True(t, updated.chat.draft) + require.False(t, updated.chat.loading) + require.True(t, updated.chat.metadataResolved) + require.True(t, updated.chat.historyResolved) + require.Nil(t, cmd) + return + } + require.Equal(t, tt.wantLoading, updated.chat.loading) + require.Len(t, mustBatchMsg(t, cmd), tt.wantBatchLen) + }) + } + }) + t.Run("EscFromChatViewRestoresListHeaderAndPadsTerminal", func(t *testing.T) { + t.Parallel() + assertReturnToList := func(t testing.TB, model expChatsTUIModel) { + t.Helper() + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + firstLine, _, _ := strings.Cut(plainText(updated.View()), "\n") + require.Equal(t, "Coder Chats", firstLine) + require.Equal(t, updated.height, countRenderedLines(plainText(updated.View()))) + } + + t.Run("SelectedChat", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + model = mustTUIModel(t, updatedModel, cmd) + model.list.loading = false + model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)} + chatID := uuid.New() + + updatedModel, cmd = model.Update(openSelectedChatMsg{chatID: chatID}) + model, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + openedChat := testChat(codersdk.ChatStatusCompleted) + openedChat.ID = chatID + openedChat.Title = "Existing chat" + updatedModel, cmd = model.Update(chatOpenedMsg{generation: model.chat.chatGeneration, chatID: chatID, chat: openedChat}) + model = mustTUIModel(t, updatedModel, cmd) + require.Contains(t, plainText(model.View()), "Existing chat") + + assertReturnToList(t, model) + }) + + t.Run("DraftChat", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + model = mustTUIModel(t, updatedModel, cmd) + model.list.loading = false + model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)} + + updatedModel, cmd = model.Update(openDraftChatMsg{}) + model, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Contains(t, plainText(model.View()), "New Chat (draft)") + + assertReturnToList(t, model) + }) + }) + t.Run("ChatViewOmitsListHeaderAndLoadingSpinner", func(t *testing.T) { + t.Parallel() + + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + model = mustTUIModel(t, updatedModel, cmd) + model.currentView = viewChat + model.list.loading = true + model.chat.loading = false + + chat := testChat(codersdk.ChatStatusCompleted) + chat.Title = "Existing chat" + model.chat.chat = &chat + model.chat.chatStatus = chat.Status + model.chat.messages = []codersdk.ChatMessage{ + testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeText, + Text: "assistant reply", + }), + } + model.chat.rebuildBlocks() + + view := plainText(model.View()) + firstLine, _, _ := strings.Cut(view, "\n") + require.Contains(t, firstLine, "Existing chat") + require.NotContains(t, view, "Coder Chats") + require.NotContains(t, view, "Loading chats") + }) + + t.Run("ReopensModelPickerAfterClosing", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + catalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: []codersdk.ChatModel{{ + ID: "provider:model-a", + Provider: "provider", + Model: "model-a", + DisplayName: "Model A", + }}, + }}, + } + model.catalog = &catalog + model.chat.modelPickerFlat = catalog.Providers[0].Models + updatedModel, cmd := model.Update(toggleModelPickerMsg{}) + updated := mustTUIModel(t, updatedModel, cmd) + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + }) + + t.Run("ModelPickerBehavior", func(t *testing.T) { + t.Parallel() + twoModelCatalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "openai", + Available: true, + Models: []codersdk.ChatModel{ + {ID: "openai:gpt-4o", Provider: "openai", Model: "gpt-4o", DisplayName: "GPT-4o"}, + {ID: "openai:gpt-4.1", Provider: "openai", Model: "gpt-4.1", DisplayName: "GPT-4.1"}, + }, + }}, + } + + t.Run("CancelClosesOverlay", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + }) + + t.Run("EscClosesPickerWithoutLeavingChat", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + model.chat.draft = true + model.chat.composerFocused = true + model.chat.composer.SetValue("keep draft") + updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) + updated := mustTUIModel(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + // ClearScreen cmd is expected + require.Equal(t, overlayNone, updated.overlay) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, "keep draft", updated.chat.composer.Value()) + }) + + t.Run("AdditionalCloseKeysClosePickerWithoutLeavingChat", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + key tea.KeyMsg + }{ + {name: "CtrlP", key: tea.KeyMsg{Type: tea.KeyCtrlP}}, + {name: "Q", key: keyRunes("q")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + model.chat.draft = true + model.chat.composerFocused = true + model.chat.composer.SetValue("keep draft") + updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) + updated := mustTUIModel(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(tt.key) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + // ClearScreen cmd is expected + require.Equal(t, overlayNone, updated.overlay) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, "keep draft", updated.chat.composer.Value()) + }) + } + }) + + t.Run("EnterSelectsModelWithoutSendingDraft", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + model.chat.draft = true + model.chat.composerFocused = true + model.chat.composer.SetValue("keep draft") + updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) + updated := mustTUIModel(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, 1, updated.chat.modelPickerCursor) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + // ClearScreen cmd is expected + require.Equal(t, overlayNone, updated.overlay) + require.NotNil(t, updated.chat.modelOverride) + require.NotNil(t, updated.modelOverride) + require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride) + require.Equal(t, "openai:gpt-4.1", *updated.modelOverride) + require.Equal(t, "keep draft", updated.chat.composer.Value()) + require.False(t, updated.chat.creatingChat) + }) + + t.Run("LoadErrorClosesOverlay", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + + updatedModel, cmd := model.Update(toggleModelPickerMsg{}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + require.NotNil(t, cmd) + require.Contains(t, plainText(updated.View()), "Loading models...") + + updatedModel, cmd = updated.Update(modelsListedMsg{err: xerrors.New("model discovery failed")}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + require.NotContains(t, plainText(updated.View()), "Loading models...") + }) + + t.Run("ScrollAndSelectModel", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + model.height = 24 + updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog}) + updated := mustTUIModel(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + + for range 4 { + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated = mustTUIModel(t, updatedModel, cmd) + } + require.Equal(t, 1, updated.chat.modelPickerCursor) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + require.NotNil(t, updated.chat.modelOverride) + require.NotNil(t, updated.modelOverride) + require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride) + require.Equal(t, "openai:gpt-4.1", *updated.modelOverride) + }) + }) + + t.Run("DiffDrawerLoadingState", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.chat = &chat + + updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayDiffDrawer, updated.overlay) + require.Len(t, mustBatchMsg(t, cmd), 2) + require.Contains(t, plainText(updated.View()), "Loading diff") + }) + + t.Run("DiffDrawerErrorState", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.width = 80 + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.chat = &chat + + updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(gitChangesMsg{err: xerrors.New("connection refused")}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Contains(t, plainText(updated.View()), "connection refused") + }) + + t.Run("OverlayDismissedOnViewSwitch", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = overlayModelPicker + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, overlayNone, updated.overlay) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.Equal(t, overlayNone, updated.overlay) + require.True(t, updated.list.loading) + require.NotNil(t, cmd) + }) + + t.Run("OverlaysMutuallyExclusive", func(t *testing.T) { + t.Parallel() + catalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: []codersdk.ChatModel{{ + ID: uuid.New().String(), + Provider: "provider", + Model: "model-a", + DisplayName: "Model A", + }}, + }}, + } + + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.overlay = overlayModelPicker + model.catalog = &catalog + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.chat = &chat + model.chat.gitChanges = []codersdk.ChatGitChange{} + + updatedModel, cmd := model.Update(toggleDiffDrawerMsg{}) + updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayDiffDrawer, updated.overlay) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + }) + + t.Run("OverlayBlocksViewKeys", func(t *testing.T) { + t.Parallel() + catalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: []codersdk.ChatModel{{ + ID: uuid.New().String(), + Provider: "provider", + Model: "model-a", + DisplayName: "Model A", + }}, + }}, + } + + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + model.catalog = &catalog + model.chat.modelPickerFlat = catalog.Providers[0].Models + + updatedModel, cmd := model.Update(toggleModelPickerMsg{}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(keyRunes("n")) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Equal(t, overlayModelPicker, updated.overlay) + require.False(t, updated.chat.draft) + }) + + t.Run("RapidViewSwitching", func(t *testing.T) { + t.Parallel() + firstChatID := uuid.New() + secondChatID := uuid.New() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.width = 100 + model.height = 40 + + updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: firstChatID}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.True(t, updated.chat.loading) + require.Nil(t, updated.chat.chat) + require.Empty(t, updated.chat.messages) + require.Len(t, mustBatchMsg(t, cmd), 3) + + firstChat := testChat(codersdk.ChatStatusCompleted) + firstChat.ID = firstChatID + updated.chat.chat = &firstChat + updated.chat.loading = false + updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "from chat A"})} + updated.chat.composer.SetValue("stale draft") + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.True(t, updated.list.loading) + + updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: secondChatID}) + updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.True(t, updated.chat.loading) + require.Nil(t, updated.chat.chat) + require.Empty(t, updated.chat.messages) + require.Empty(t, updated.chat.composer.Value()) + require.False(t, updated.chat.draft) + require.Len(t, mustBatchMsg(t, cmd), 3) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.True(t, updated.list.loading) + }) + }) + + t.Run("ChatView/MessageReceiving", func(t *testing.T) { + t.Parallel() + setup := func(metadataResolved, historyResolved bool) chatViewModel { + model := newTestChatViewModel(nil) + model.loading, model.metadataResolved, model.historyResolved = true, metadataResolved, historyResolved + return model + } + t.Run("ChatOpenedSuccessAndError", func(t *testing.T) { + t.Parallel() + diffStatus := &codersdk.ChatDiffStatus{ChatID: uuid.New()} + chat := testChat(codersdk.ChatStatusRunning) + chat.DiffStatus = diffStatus + chat.PlanMode = codersdk.ChatPlanModePlan + updated, cmd := setup(false, true).Update(chatOpenedMsg{chat: chat}) + require.NotNil(t, cmd) + require.Equal(t, chat.ID, updated.chat.ID) + require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus) + require.Equal(t, diffStatus, updated.diffStatus) + require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) + require.False(t, updated.loading) + require.Nil(t, updated.err) + updated, cmd = setup(false, true).Update(chatOpenedMsg{err: xerrors.New("open failed")}) + require.Nil(t, cmd) + require.Equal(t, "open failed", updated.err.Error()) + require.False(t, updated.loading) + }) + t.Run("ChatHistorySuccessAndError", func(t *testing.T) { + t.Parallel() + usageA := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(10)} + usageB := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(20)} + second := testMessage(2, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "second"}) + second.Usage = usageA + third := testMessage(3, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: "third"}) + third.Usage = usageB + messages := []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "first"}), second, third} + updated, cmd := setup(true, false).Update(chatHistoryMsg{messages: messages}) + require.Nil(t, cmd) + require.Equal(t, messages, updated.messages) + require.Len(t, updated.blocks, 3) + require.Equal(t, usageB, updated.lastUsage) + require.False(t, updated.loading) + updated, cmd = setup(true, false).Update(chatHistoryMsg{err: xerrors.New("history failed")}) + require.Nil(t, cmd) + require.Equal(t, "history failed", updated.err.Error()) + require.False(t, updated.loading) + }) + t.Run("OpenHistoryBothSucceedOutOfOrder", func(t *testing.T) { + t.Parallel() + model := setup(false, false) + model, _ = model.Update(chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}}) + require.True(t, model.loading) + require.Nil(t, model.err) + model.streaming = true + chat := testChat(codersdk.ChatStatusCompleted) + model, _ = model.Update(chatOpenedMsg{chat: chat}) + require.False(t, model.loading) + require.Nil(t, model.err) + require.Len(t, model.messages, 1) + }) + t.Run("OpenHistoryBothFail", func(t *testing.T) { + t.Parallel() + model := setup(false, false) + model, _ = model.Update(chatOpenedMsg{err: xerrors.New("open err")}) + require.True(t, model.loading) + model, _ = model.Update(chatHistoryMsg{err: xerrors.New("history err")}) + require.False(t, model.loading) + require.Equal(t, "open err", model.err.Error()) + }) + t.Run("StaleAsyncMessagesAreDropped", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + chat := testChat(codersdk.ChatStatusCompleted) + model.setChat(chat) + model.chatGeneration = 1 + model.loading = false + + before := model + updated, cmd := model.Update(chatOpenedMsg{ + generation: 0, + chatID: uuid.New(), + chat: testChat(codersdk.ChatStatusRunning), + }) + require.Nil(t, cmd) + require.Equal(t, before.chat, updated.chat) + require.Equal(t, before.loading, updated.loading) + require.Equal(t, before.messages, updated.messages) + require.Equal(t, before.err, updated.err) + }) + + t.Run("StaleSessionMessagesAreDroppedByGeneration", func(t *testing.T) { + t.Parallel() + type staleGenerationCase struct { + name string + msg tea.Msg + draft bool + } + type staleGenerationSnapshot struct { + loading bool + err error + chat *codersdk.Chat + pendingComposerText string + composerValue string + messages []codersdk.ChatMessage + draft bool + creatingChat bool + interrupting bool + queuedMessages []codersdk.ChatQueuedMessage + } + + startingState := func(draft bool) chatViewModel { + model := newTestChatViewModel(nil) + model.chatGeneration = 2 + model.loading = false + model.pendingComposerText = "pending" + if draft { + model.draft = true + model.composer.SetValue("draft text") + return model + } + model.creatingChat = true + model.interrupting = true + model.setChat(testChat(codersdk.ChatStatusCompleted)) + model.composer.SetValue("current") + return model + } + snapshot := func(model chatViewModel) staleGenerationSnapshot { + return staleGenerationSnapshot{ + loading: model.loading, + err: model.err, + chat: model.chat, + pendingComposerText: model.pendingComposerText, + composerValue: model.composer.Value(), + messages: model.messages, + draft: model.draft, + creatingChat: model.creatingChat, + interrupting: model.interrupting, + queuedMessages: model.queuedMessages, + } + } + + tests := []staleGenerationCase{ + {name: "WriteSide/chatCreatedMsg", msg: chatCreatedMsg{generation: 1, chat: testChat(codersdk.ChatStatusRunning)}}, + {name: "WriteSide/messageSentMsg", msg: messageSentMsg{generation: 1, resp: codersdk.CreateChatMessageResponse{}}}, + {name: "WriteSide/chatInterruptedMsg", msg: chatInterruptedMsg{generation: 1, chat: testChat(codersdk.ChatStatusCompleted)}}, + {name: "Draft/chatOpenedMsg", msg: chatOpenedMsg{generation: 1, chatID: uuid.New(), chat: testChat(codersdk.ChatStatusCompleted)}, draft: true}, + {name: "Draft/chatHistoryMsg", msg: chatHistoryMsg{generation: 1, chatID: uuid.New(), messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}}, draft: true}, + {name: "Draft/chatStreamEventMsg", msg: chatStreamEventMsg{generation: 1, chatID: uuid.New(), event: testTextPartEvent("stale")}, draft: true}, + {name: "Draft/gitChangesMsg", msg: gitChangesMsg{generation: 1, chatID: uuid.New()}, draft: true}, + {name: "Draft/diffContentsMsg", msg: diffContentsMsg{generation: 1, chatID: uuid.New()}, draft: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := startingState(tt.draft) + before := snapshot(model) + updated, cmd := model.Update(tt.msg) + require.Nil(t, cmd) + require.Equal(t, before, snapshot(updated)) + }) + } + }) + + t.Run("ErrorThenRetrySucceeds", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + errMsg tea.Msg + retryMsg tea.Msg + needsClient bool + composerText string + wantBlocks int + wantRetryCmd bool + }{ + {name: "ChatOpened", errMsg: chatOpenedMsg{err: xerrors.New("open failed")}, retryMsg: chatOpenedMsg{chat: testChat(codersdk.ChatStatusRunning)}}, + {name: "History", errMsg: chatHistoryMsg{err: xerrors.New("history failed")}, retryMsg: chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "recovered"})}}, wantBlocks: 1}, + {name: "Send", needsClient: true, composerText: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}, wantRetryCmd: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + if tt.needsClient { + model = newTestChatViewModel(failingExperimentalClient()) + model.loading = false + chat := testChat(codersdk.ChatStatusCompleted) + model.chat = &chat + model.chatStatus = chat.Status + model.composer.SetValue(tt.composerText) + } + updated, cmd := model.Update(tt.errMsg) + require.Nil(t, cmd) + require.Error(t, updated.err) + if tt.retryMsg != nil { + updated, cmd = updated.Update(tt.retryMsg) + require.Nil(t, updated.err) + switch retryMsg := tt.retryMsg.(type) { + case chatOpenedMsg: + require.NotNil(t, cmd) + require.NotNil(t, updated.chat) + require.Equal(t, retryMsg.chat.ID, updated.chat.ID) + case chatHistoryMsg: + require.Nil(t, cmd) + require.Equal(t, retryMsg.messages, updated.messages) + require.Len(t, updated.blocks, tt.wantBlocks) + } + } + if !tt.wantRetryCmd { + return + } + require.Equal(t, tt.composerText, updated.composer.Value()) + require.Contains(t, updated.View(), "send failed") + updated.composer.SetValue("retry me") + retried, retryCmd := updated.sendMessage() + require.NotNil(t, retryCmd) + require.True(t, retried.autoFollow) + require.Empty(t, retried.composer.Value()) + _, ok := mustMsg(t, retryCmd).(messageSentMsg) + require.True(t, ok) + }) + } + }) + t.Run("ChatHistoryEdgeCases", func(t *testing.T) { + t.Parallel() + cases := []struct { + name string + messages []codersdk.ChatMessage + wantNil bool + }{ + {name: "NilMessages", wantNil: true}, + {name: "EmptyMessages", messages: []codersdk.ChatMessage{}, wantNil: false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.messages = []codersdk.ChatMessage{ + testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}), + } + model.rebuildBlocks() + require.Len(t, model.blocks, 1) + + var updated chatViewModel + require.NotPanics(t, func() { + updated, _ = model.Update(chatHistoryMsg{messages: tt.messages}) + }) + require.Equal(t, tt.wantNil, updated.messages == nil) + if !tt.wantNil { + require.Empty(t, updated.messages) + } + require.Empty(t, updated.blocks) + }) + } + }) + }) + + t.Run("ChatView/StreamEvents", func(t *testing.T) { + t.Parallel() + applyStream := func(model chatViewModel, event codersdk.ChatStreamEvent) (chatViewModel, tea.Cmd) { + return model.Update(chatStreamEventMsg{event: event}) + } + messageEvent := func(message codersdk.ChatMessage) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message} + } + usage := &codersdk.ChatMessageUsage{OutputTokens: int64Ref(7)} + finalMessage := testMessage(9, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "final"}) + finalMessage.Usage = usage + for _, tt := range []struct { + name string + seedEvents []codersdk.ChatStreamEvent + reconnecting bool + event codersdk.ChatStreamEvent + wantMessages int + wantAccumulatorText string + wantAccumulatorArgs string + wantBlockKind chatBlockKind + wantBlockText string + wantBlockArgs string + wantUsage *codersdk.ChatMessageUsage + }{ + { + name: "MessagePartTextAppendsAndRebuildsBlocks", + seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("hel")}, + event: testTextPartEvent("lo"), + wantAccumulatorText: "hello", + wantBlockText: "hello", + }, + { + name: "MessagePartToolCallDeltaAccumulatesArgs", + seedEvents: []codersdk.ChatStreamEvent{testToolCallDeltaEvent("tc-1", "search", `{"q":"hel`)}, + event: testToolCallDeltaEvent("tc-1", "search", `lo"}`), + wantAccumulatorArgs: `{"q":"hello"}`, + wantBlockKind: blockToolCall, + wantBlockArgs: `{"q":"hello"}`, + }, + { + name: "MessageFinalizesAndResetsAccumulator", + seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("partial")}, + reconnecting: true, + event: messageEvent(finalMessage), + wantMessages: 1, + wantBlockText: "final", + wantUsage: usage, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.reconnecting = tt.reconnecting + for _, event := range tt.seedEvents { + model, _ = applyStream(model, event) + } + var cmd tea.Cmd + model, cmd = applyStream(model, tt.event) + require.Nil(t, cmd) + assertStreamCase(t, model, tt.wantMessages, tt.wantAccumulatorText, tt.wantAccumulatorArgs, tt.wantBlockKind, tt.wantBlockText, tt.wantBlockArgs, tt.wantUsage) + }) + } + t.Run("StatusEventRouting", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + chat := testChat(codersdk.ChatStatusWaiting) + model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status + updated, cmd := model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: chat.ID, + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}, + }}) + require.NotNil(t, cmd) + require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus) + require.Equal(t, codersdk.ChatStatusRunning, updated.chat.Status) + chat.Status = codersdk.ChatStatusWaiting + model.chatStatus = codersdk.ChatStatusWaiting + updated, cmd = model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: uuid.New(), + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}, + }}) + require.Nil(t, cmd) + require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus) + require.Equal(t, codersdk.ChatStatusWaiting, updated.chat.Status) + }) + t.Run("ErrorSetsErr", func(t *testing.T) { + t.Parallel() + updated, cmd := applyStream(newTestChatViewModel(nil), codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeError, + Error: &codersdk.ChatStreamError{Message: "stream blew up"}, + }) + require.Nil(t, cmd) + require.Equal(t, "stream error: stream blew up", updated.err.Error()) + }) + queuedMessages := []codersdk.ChatQueuedMessage{ + testQueuedMessage(1, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued text"}), + } + existingMessages := []codersdk.ChatMessage{ + testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}), + } + for _, tt := range []struct { + name string + messages []codersdk.ChatMessage + event codersdk.ChatStreamEvent + wantMessages []codersdk.ChatMessage + wantQueuedMessages []codersdk.ChatQueuedMessage + wantBlockText string + }{ + { + name: "QueueUpdateReplacesQueuedMessages", + event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeQueueUpdate, QueuedMessages: queuedMessages}, + wantQueuedMessages: queuedMessages, + wantBlockText: "queued text", + }, + { + name: "StreamEventWithNilPartIsIgnored", + messages: existingMessages, + event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart}, + wantMessages: existingMessages, + wantBlockText: "existing", + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.messages = tt.messages + model.rebuildBlocks() + updated, cmd := applyStream(model, tt.event) + require.Nil(t, cmd) + model = updated + require.Equal(t, tt.wantMessages, model.messages) + require.Equal(t, tt.wantQueuedMessages, model.queuedMessages) + require.Len(t, model.blocks, 1) + require.Equal(t, tt.wantBlockText, model.blocks[0].text) + require.False(t, model.accumulator.isPending()) + }) + } + t.Run("StreamEventErrorShowsInView", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12}) + model.loading = false + chat := testChat(codersdk.ChatStatusCompleted) + model.chat = &chat + model.chatStatus = chat.Status + model.messages = []codersdk.ChatMessage{ + testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}), + } + model.rebuildBlocks() + updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("websocket closed")}) + view := plainText(updated.View()) + require.Contains(t, view, chat.Title) + require.Contains(t, view, "existing response") + require.Contains(t, view, "websocket closed") + require.Contains(t, view, "Type a message") + require.Contains(t, view, "esc: back") + }) + t.Run("LoadingViewKeepsChatChrome", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.loading = true + model.metadataResolved = false + model.historyResolved = false + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12}) + view := plainText(model.View()) + require.Contains(t, view, "New Chat (draft)") + require.Contains(t, view, "Loading chat...") + require.Contains(t, view, "Type a message") + require.Contains(t, view, "esc: back") + }) + t.Run("MultipleStreamErrorsOnlyShowLatest", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 12}) + model.loading = false + updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("first error")}) + updated = mustChatViewUpdate(t, updated, chatStreamEventMsg{err: xerrors.New("second error")}) + view := updated.View() + require.Contains(t, view, "second error") + require.NotContains(t, view, "first error") + }) + t.Run("StreamAccumulatorFinalToolCallUpsertsExistingPart", func(t *testing.T) { + t.Parallel() + newToolCallDelta := func(toolCallID, toolName, argsDelta string) codersdk.ChatStreamMessagePart { + return codersdk.ChatStreamMessagePart{ + Role: codersdk.ChatMessageRoleAssistant, + Part: codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: toolCallID, + ToolName: toolName, + ArgsDelta: argsDelta, + }, + } + } + newFinalToolCall := func(toolCallID, toolName, args string) codersdk.ChatStreamMessagePart { + return codersdk.ChatStreamMessagePart{ + Role: codersdk.ChatMessageRoleAssistant, + Part: codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: toolCallID, + ToolName: toolName, + Args: json.RawMessage(args), + }, + } + } + cases := []struct { + name string + seed []codersdk.ChatStreamMessagePart + final codersdk.ChatStreamMessagePart + want []codersdk.ChatMessagePart + }{ + { + name: "ReplaceExistingToolCall", + seed: []codersdk.ChatStreamMessagePart{ + newToolCallDelta("tc-1", "search", `{"q":"hel`), + }, + final: newFinalToolCall("tc-1", "search", `{"q":"hello"}`), + want: []codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "tc-1", + ToolName: "search", + Args: json.RawMessage(`{"q":"hello"}`), + }}, + }, + { + name: "AppendNewToolCallID", + seed: []codersdk.ChatStreamMessagePart{ + newToolCallDelta("tc-1", "search", `{"q":"hel`), + }, + final: newFinalToolCall("tc-2", "lookup", `{"id":"42"}`), + want: []codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "tc-1", + ToolName: "search", + Args: json.RawMessage(`{"q":"hel`), + }, + { + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "tc-2", + ToolName: "lookup", + Args: json.RawMessage(`{"id":"42"}`), + }, + }, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var accumulator streamAccumulator + for _, delta := range tt.seed { + accumulator.applyDelta(delta) + } + accumulator.applyDelta(tt.final) + require.True(t, accumulator.pending) + require.Equal(t, codersdk.ChatMessageRoleAssistant, accumulator.role) + require.Equal(t, tt.want, accumulator.parts) + }) + } + }) + t.Run("MessageDeduplication", func(t *testing.T) { + t.Parallel() + toolRoundTripParts := []codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: "tool-1", ToolName: "search", Args: json.RawMessage(`{"q":"hello"}`)}, + {Type: codersdk.ChatMessagePartTypeToolResult, ToolCallID: "tool-1", ToolName: "search", Result: json.RawMessage(`{"ok":true}`)}, + } + model := newTestChatViewModel(nil) + model.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, toolRoundTripParts...)} + model.accumulator = streamAccumulator{pending: true, role: codersdk.ChatMessageRoleAssistant, parts: toolRoundTripParts} + model.rebuildBlocks() + require.Len(t, model.messages, 1) + require.Len(t, model.blocks, 1) + require.True(t, model.accumulator.isPending()) + require.Equal(t, blockToolResult, model.blocks[0].kind) + require.Equal(t, "tool-1", model.blocks[0].toolID) + }) + t.Run("StaleStreamEventsAreDroppedByGeneration", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + chat := testChat(codersdk.ChatStatusRunning) + model.setChat(chat) + model.chatGeneration = 1 + model.streaming = true + staleMsg := chatStreamEventMsg{ + chatID: uuid.New(), + event: testTextPartEvent("should be ignored"), + } + updated, cmd := model.Update(staleMsg) + require.Nil(t, cmd) + require.Empty(t, updated.accumulator.parts) + require.Equal(t, model.chatStatus, updated.chatStatus) + require.Equal(t, model.blocks, updated.blocks) + }) + t.Run("IntentionalCloseSkipsReconnect", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + chat := testChat(codersdk.ChatStatusRunning) + model.setChat(chat) + model.streaming = true + model.stopStream() + require.True(t, model.intentionalClose) + eofMsg := chatStreamEventMsg{ + chatID: chat.ID, + err: io.EOF, + } + updated, cmd := model.Update(eofMsg) + require.Nil(t, cmd) + require.False(t, updated.streaming) + require.False(t, updated.reconnecting) + require.False(t, updated.intentionalClose) + require.NoError(t, updated.err) + }) + t.Run("EOFStopsStreamingAndAttemptsReconnectWhenInterruptible", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(failingExperimentalClient()) + chat := testChat(codersdk.ChatStatusPending) + model.setChat(chat) + model.streaming = true + updated, cmd := model.Update(chatStreamEventMsg{chatID: chat.ID, err: io.EOF}) + require.NotNil(t, cmd) + require.False(t, updated.streaming) + require.True(t, updated.reconnecting) + }) + t.Run("MessageEventsDeduplicateByID", func(t *testing.T) { + t.Parallel() + message := testMessage(11, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}) + model, _ := applyStream(newTestChatViewModel(nil), messageEvent(message)) + model, cmd := applyStream(model, messageEvent(message)) + require.Nil(t, cmd) + require.Len(t, model.messages, 1) + }) + }) + t.Run("ChatView/Sending", func(t *testing.T) { + t.Parallel() + t.Run("DeliveredMessageIsAddedAndBlocksRebuilt", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + message := testMessage(21, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"}) + + updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) + require.Nil(t, cmd) + require.Len(t, updated.messages, 1) + require.Len(t, updated.blocks, 1) + require.Equal(t, "delivered", updated.blocks[0].text) + }) + + t.Run("DisconnectedSendRestartsStream", func(t *testing.T) { + t.Parallel() + chat := testChat(codersdk.ChatStatusCompleted) + message := testMessage(22, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "sent"}) + streamQueryCh := make(chan string, 1) + streamErrCh := make(chan error, 1) + client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { + wantPath := fmt.Sprintf("/api/experimental/chats/%s/stream", chat.ID) + if req.URL.Path != wantPath { + select { + case streamErrCh <- xerrors.Errorf("stream path %q, want %q", req.URL.Path, wantPath): + default: + } + rw.WriteHeader(http.StatusNotFound) + return + } + + conn, err := websocket.Accept(rw, req, nil) + if err != nil { + select { + case streamErrCh <- err: + default: + } + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + select { + case streamQueryCh <- req.URL.RawQuery: + default: + } + }) + + model := newTestChatViewModel(client) + model.setChat(chat) + + updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) + defer updated.stopStream() + require.NotNil(t, cmd) + require.True(t, updated.streaming) + require.NotNil(t, updated.streamCloser) + require.NotNil(t, updated.streamEventCh) + require.Len(t, updated.messages, 1) + + select { + case err := <-streamErrCh: + require.NoError(t, err) + case query := <-streamQueryCh: + require.Equal(t, fmt.Sprintf("after_id=%d", message.ID), query) + case <-time.After(time.Second): + t.Fatal("timed out waiting for restarted chat stream connection") + } + }) + + t.Run("ActiveStreamDoesNotReconnectOnSend", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + chat := testChat(codersdk.ChatStatusCompleted) + message := testMessage(24, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"}) + model.setChat(chat) + model.streaming = true + + updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}}) + require.Nil(t, cmd) + require.True(t, updated.streaming) + require.Len(t, updated.messages, 1) + }) + + t.Run("QueuedResponseUpdatesQueuedMessages", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + queued := testQueuedMessage(22, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued"}) + + updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{ + Queued: true, + QueuedMessage: &queued, + }}) + require.Nil(t, cmd) + require.Len(t, updated.queuedMessages, 1) + require.Len(t, updated.blocks, 1) + require.Equal(t, "queued", updated.blocks[0].text) + }) + + t.Run("SendCreateErrorHandling", func(t *testing.T) { + t.Parallel() + tests := []struct { + name, composerText, wantComposer string + draft, setChat, useSend, typeNewInput, wantRetry bool + errMsg tea.Msg + }{ + {name: "send preserves existing composer text", composerText: "keep me", wantComposer: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}}, + {name: "send restores pending text", composerText: "my message", wantComposer: "my message", setChat: true, useSend: true, errMsg: messageSentMsg{err: xerrors.New("network error")}}, + {name: "create restores pending text", composerText: "first message", wantComposer: "first message", draft: true, useSend: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}}, + {name: "create error allows retry", composerText: "keep draft", wantComposer: "keep draft", draft: true, wantRetry: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}}, + {name: "messageSent error does not overwrite newer input", composerText: "original", wantComposer: "new input", setChat: true, useSend: true, typeNewInput: true, errMsg: messageSentMsg{err: xerrors.New("fail")}}, + {name: "chatCreated error does not overwrite newer input", composerText: "original", wantComposer: "new input", draft: true, useSend: true, typeNewInput: true, errMsg: chatCreatedMsg{err: xerrors.New("fail")}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var client *codersdk.ExperimentalClient + if tt.wantRetry { + client = failingExperimentalClient() + } + model := newTestChatViewModel(client) + model.loading = false + if tt.draft { + model.draft = true + } + if tt.setChat { + chat := testChat(codersdk.ChatStatusCompleted) + model.setChat(chat) + } + model.composer.SetValue(tt.composerText) + if tt.useSend { + model, _ = model.sendMessage() + require.Empty(t, model.composer.Value()) + require.Equal(t, tt.composerText, model.pendingComposerText) + if tt.draft { + require.True(t, model.creatingChat) + } + } + if tt.typeNewInput { + model.composer.SetValue("new input") + } + updated, cmd := model.Update(tt.errMsg) + require.Nil(t, cmd) + model = updated + require.Error(t, model.err) + switch msg := tt.errMsg.(type) { + case messageSentMsg: + require.Equal(t, msg.err.Error(), model.err.Error()) + case chatCreatedMsg: + require.Equal(t, msg.err.Error(), model.err.Error()) + } + require.Equal(t, tt.wantComposer, model.composer.Value()) + if tt.wantRetry { + require.True(t, model.draft) + require.Contains(t, model.View(), "create failed") + model.composer.SetValue("retry draft") + retried, retryCmd := model.sendMessage() + require.NotNil(t, retryCmd) + require.True(t, retried.draft) + require.Empty(t, retried.composer.Value()) + _, ok := mustMsg(t, retryCmd).(chatCreatedMsg) + require.True(t, ok) + } + if tt.draft && tt.useSend { + require.False(t, model.creatingChat) + } + }) + } + }) + + t.Run("DuplicateDraftCreateIsIgnored", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.draft = true + model.loading = false + model.creatingChat = true + model.composer.SetValue("hello") + + updated, cmd := model.sendMessage() + require.Nil(t, cmd) + require.Equal(t, "hello", updated.composer.Value()) + }) + }) + + t.Run("ChatView/ModelOverrideMapsCanonicalModelID", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + draft bool + }{ + {name: "DraftCreateReturnsChatCreatedMsg", draft: true}, + {name: "SendMessageReturnsMessageSentMsg", draft: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + modelConfigID := uuid.New() + organizationID := uuid.New() + modelOverride := "provider:model" + createdChat := testChat(codersdk.ChatStatusWaiting) + chat := testChat(codersdk.ChatStatusCompleted) + var createReq *codersdk.CreateChatRequest + var messageReq *codersdk.CreateChatMessageRequest + client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodGet && req.URL.Path == "/api/experimental/chats/model-configs": + require.NoError(t, json.NewEncoder(rw).Encode([]codersdk.ChatModelConfig{{ID: modelConfigID, Provider: "provider", Model: "model"}})) + case req.Method == http.MethodPost && req.URL.Path == "/api/experimental/chats": + createReq = new(codersdk.CreateChatRequest) + require.NoError(t, json.NewDecoder(req.Body).Decode(createReq)) + rw.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(rw).Encode(createdChat)) + case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID): + messageReq = new(codersdk.CreateChatMessageRequest) + require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq)) + require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{})) + default: + t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) + } + }) + model := newTestChatViewModel(client) + if tt.draft { + model.draft = true + } else { + model.setChat(chat) + } + model.loading = false + model.modelOverride = &modelOverride + model.organizationID = organizationID + model.planMode = codersdk.ChatPlanModePlan + model.composer.SetValue("hello") + updated, cmd := model.sendMessage() + require.NotNil(t, cmd) + require.Empty(t, updated.composer.Value()) + if tt.draft { + msg, ok := mustMsg(t, cmd).(chatCreatedMsg) + require.True(t, ok) + require.NoError(t, msg.err) + require.NotNil(t, createReq) + require.Equal(t, organizationID, createReq.OrganizationID) + require.NotNil(t, createReq.ModelConfigID) + require.Equal(t, modelConfigID, *createReq.ModelConfigID) + require.Equal(t, codersdk.ChatPlanModePlan, createReq.PlanMode) + require.Equal(t, createdChat.ID, msg.chat.ID) + return + } + msg, ok := mustMsg(t, cmd).(messageSentMsg) + require.True(t, ok) + require.NoError(t, msg.err) + require.NotNil(t, messageReq) + require.NotNil(t, messageReq.ModelConfigID) + require.Equal(t, modelConfigID, *messageReq.ModelConfigID) + require.NotNil(t, messageReq.PlanMode) + require.Equal(t, codersdk.ChatPlanModePlan, *messageReq.PlanMode) + }) + } + }) + t.Run("ChatView/SendMessageExplicitlyClearsPlanMode", func(t *testing.T) { + t.Parallel() + chat := testChat(codersdk.ChatStatusCompleted) + var messageReq *codersdk.CreateChatMessageRequest + client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID): + messageReq = new(codersdk.CreateChatMessageRequest) + require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq)) + require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{})) + default: + t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) + } + }) + model := newTestChatViewModel(client) + model.setChat(chat) + model.loading = false + model.composer.SetValue("hello") + + updated, cmd := model.sendMessage() + require.NotNil(t, cmd) + require.Empty(t, updated.composer.Value()) + + msg, ok := mustMsg(t, cmd).(messageSentMsg) + require.True(t, ok) + require.NoError(t, msg.err) + require.NotNil(t, messageReq) + require.NotNil(t, messageReq.PlanMode) + require.Empty(t, *messageReq.PlanMode) + }) + + t.Run("ChatView/ChatCreatedPromotesDraft", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.draft = true + model.streaming = true + chat := testChat(codersdk.ChatStatusWaiting) + + updated, cmd := model.Update(chatCreatedMsg{chat: chat}) + require.Nil(t, cmd) + require.NotNil(t, updated.chat) + require.Equal(t, chat.ID, updated.chat.ID) + require.False(t, updated.draft) + require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus) + require.Nil(t, updated.err) + }) + + t.Run("ChatView/Interrupts", func(t *testing.T) { + t.Parallel() + newInterruptModel := func(status codersdk.ChatStatus) chatViewModel { + model := newTestChatViewModel(failingExperimentalClient()) + model.setChat(testChat(status)) + return model + } + + t.Run("InterruptedChatClearsInterruptingAndUpdatesStatus", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.interrupting = true + chat := testChat(codersdk.ChatStatusCompleted) + + updated, cmd := model.Update(chatInterruptedMsg{chat: chat}) + require.Nil(t, cmd) + require.False(t, updated.interrupting) + require.Equal(t, chat.ID, updated.chat.ID) + require.Equal(t, codersdk.ChatStatusCompleted, updated.chatStatus) + }) + + t.Run("InterruptedChatErrorClearsInterruptingAndSetsErr", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.interrupting = true + + updated, cmd := model.Update(chatInterruptedMsg{err: xerrors.New("interrupt failed")}) + require.Nil(t, cmd) + require.False(t, updated.interrupting) + require.Equal(t, "interrupt failed", updated.err.Error()) + }) + + tests := []struct { + name string + chatStatus codersdk.ChatStatus + alreadyInterrupting bool + expectedInterrupting bool + }{ + {name: "DoubleInterrupt", chatStatus: codersdk.ChatStatusRunning, alreadyInterrupting: true, expectedInterrupting: true}, + {name: "IdleChat", chatStatus: codersdk.ChatStatusCompleted}, + } + for _, tt := range tests { + t.Run("CtrlXNoOpCases/"+tt.name, func(t *testing.T) { + t.Parallel() + model := newInterruptModel(tt.chatStatus) + model.interrupting = tt.alreadyInterrupting + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) + require.Nil(t, cmd) + require.Equal(t, tt.expectedInterrupting, updated.interrupting) + }) + } + + t.Run("CtrlXInterruptsRunningChat", func(t *testing.T) { + t.Parallel() + model := newInterruptModel(codersdk.ChatStatusRunning) + require.True(t, model.composerFocused) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) + require.NotNil(t, cmd) + require.True(t, updated.interrupting) + require.True(t, updated.composerFocused) + }) + + t.Run("TabKeepsFocusSwitchBehaviorWhileRunningChat", func(t *testing.T) { + t.Parallel() + model := newInterruptModel(codersdk.ChatStatusRunning) + require.True(t, model.composerFocused) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab}) + require.Nil(t, cmd) + require.False(t, updated.interrupting) + require.False(t, updated.composerFocused) + }) + + t.Run("ViewShowsCtrlXInterruptHelp", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) + model.setChat(testChat(codersdk.ChatStatusRunning)) + model.loading = false + + view := plainText(model.View()) + require.Contains(t, view, "ctrl+x: interrupt") + require.NotContains(t, view, "ctrl+i: interrupt") + }) + + t.Run("ViewShowsPlanModeBadge", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) + model.loading = false + execView := model.View() + require.Contains(t, plainText(execView), "mode: exec") + require.Contains(t, execView, model.styles.modeBadgeExec.Render("exec")) + + model.planMode = codersdk.ChatPlanModePlan + planView := model.View() + view := plainText(planView) + require.Contains(t, view, "mode: plan") + require.Contains(t, planView, model.styles.modeBadgePlan.Render("plan")) + require.Contains(t, view, "shift+tab: switch mode") + }) + + t.Run("PlanModeUpdateErrorRollsBackLocalModeAndShowsBanner", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + current codersdk.ChatPlanMode + want codersdk.ChatPlanMode + }{ + {name: "BackToCode", current: codersdk.ChatPlanModePlan, want: ""}, + {name: "BackToPlan", current: "", want: codersdk.ChatPlanModePlan}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12}) + model.setChat(testChat(codersdk.ChatStatusCompleted)) + model.planMode = tt.current + + updated, cmd := model.Update(chatPlanModeUpdatedMsg{err: xerrors.New("update failed")}) + require.Nil(t, cmd) + require.Equal(t, tt.want, updated.planMode) + require.EqualError(t, updated.err, "update failed") + require.Contains(t, plainText(updated.View()), "update failed") + }) + } + }) + }) + + t.Run("ChatView/Keyboard", func(t *testing.T) { + t.Parallel() + t.Run("KeyboardShortcutRouting", func(t *testing.T) { + t.Parallel() + isToggleModelPicker := func(msg tea.Msg) bool { _, ok := msg.(toggleModelPickerMsg); return ok } + isToggleDiffDrawer := func(msg tea.Msg) bool { _, ok := msg.(toggleDiffDrawerMsg); return ok } + tests := []struct { + name string + key tea.KeyType + composerFocused bool + composerValue string + assert func(tea.Msg) bool + }{ + {name: "CtrlP/Focused", key: tea.KeyCtrlP, composerFocused: true, composerValue: "draft", assert: isToggleModelPicker}, + {name: "CtrlP/Unfocused", key: tea.KeyCtrlP, assert: isToggleModelPicker}, + {name: "CtrlD/Focused", key: tea.KeyCtrlD, composerFocused: true, composerValue: "draft", assert: isToggleDiffDrawer}, + {name: "CtrlD/Unfocused", key: tea.KeyCtrlD, assert: isToggleDiffDrawer}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.composerFocused = tt.composerFocused + model.composer.SetValue(tt.composerValue) + + updated, cmd := model.Update(tea.KeyMsg{Type: tt.key}) + require.NotNil(t, cmd) + require.Equal(t, tt.composerFocused, updated.composerFocused) + require.Equal(t, tt.composerValue, updated.composer.Value()) + require.True(t, tt.assert(mustMsg(t, cmd))) + }) + } + }) + + t.Run("ShiftTabTogglesPlanMode", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.composer.SetValue("draft") + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + require.Nil(t, cmd) + require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) + require.Equal(t, "draft", updated.composer.Value()) + + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + require.Nil(t, cmd) + require.Empty(t, updated.planMode) + require.Equal(t, "draft", updated.composer.Value()) + }) + + t.Run("TabOnlySwitchesComposerFocus", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.planMode = codersdk.ChatPlanModePlan + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab}) + require.Nil(t, cmd) + require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) + require.False(t, updated.composerFocused) + }) + + t.Run("ShiftTabDraftChatDefersPlanModePersistence", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.draft = true + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + require.Nil(t, cmd) + require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) + }) + + t.Run("ShiftTabExistingChatUpdatesPlanModeImmediately", func(t *testing.T) { + t.Parallel() + chat := testChat(codersdk.ChatStatusCompleted) + var requests []codersdk.UpdateChatRequest + client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) { + switch { + case req.Method == http.MethodPatch && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s", chat.ID): + var updateReq codersdk.UpdateChatRequest + require.NoError(t, json.NewDecoder(req.Body).Decode(&updateReq)) + requests = append(requests, updateReq) + rw.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected %s %s", req.Method, req.URL.Path) + } + }) + model := newTestChatViewModel(client) + model.setChat(chat) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + require.NotNil(t, cmd) + require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode) + + msg, ok := mustMsg(t, cmd).(chatPlanModeUpdatedMsg) + require.True(t, ok) + require.NoError(t, msg.err) + require.Len(t, requests, 1) + require.NotNil(t, requests[0].PlanMode) + require.Equal(t, codersdk.ChatPlanModePlan, *requests[0].PlanMode) + + updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + require.NotNil(t, cmd) + require.Empty(t, updated.planMode) + + msg, ok = mustMsg(t, cmd).(chatPlanModeUpdatedMsg) + require.True(t, ok) + require.NoError(t, msg.err) + require.Len(t, requests, 2) + require.NotNil(t, requests[1].PlanMode) + require.Empty(t, *requests[1].PlanMode) + }) + + t.Run("CtrlPFromListViewDoesNotOpenModelPicker", func(t *testing.T) { + t.Parallel() + model := newTestTUIModel() + model.currentView = viewList + model.list.loading = false + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.Equal(t, overlayNone, updated.overlay) + }) + }) + + t.Run("ChatView/ViewportScrolling", func(t *testing.T) { + t.Parallel() + applyWindowSize := func(t *testing.T, model expChatsTUIModel, width int, height int) expChatsTUIModel { + t.Helper() + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + return mustTUIModel(t, updatedModel, cmd) + } + scrollableModel := func(t *testing.T, keys ...tea.KeyType) chatViewModel { + t.Helper() + model := newTestChatViewModel(nil) + model.loading = false + chat := testChat(codersdk.ChatStatusCompleted) + model.chat = &chat + model.chatStatus = chat.Status + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 20}) + model.messages = overflowingMessages(24) + model.rebuildBlocks() + model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: tea.KeyTab}) + require.False(t, model.composerFocused) + require.True(t, model.autoFollow) + require.True(t, model.viewport.AtBottom()) + require.Greater(t, model.viewport.YOffset, 0) + for _, key := range keys { + model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: key}) + } + return model + } + streamMessage := func(id int64) chatStreamEventMsg { + message := testMessage( + id, + codersdk.ChatMessageRoleAssistant, + codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: strings.Repeat("new content ", 24)}, + ) + return chatStreamEventMsg{event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message}} + } + updateView := func(model chatViewModel, msg tea.Msg) chatViewModel { + updated, _ := model.Update(msg) + return updated + } + t.Run("ViewportHeights", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + height int + viewChat bool + messageCount int + wantChatHeight int + wantViewportHeight int + }{ + {"Standard", 40, false, 0, 39, 33}, + {"MinimumZero", 5, false, 0, -1, 0}, + {"ViewFitsTerminal", 40, true, 24, -1, -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := applyWindowSize(t, newTestTUIModel(), 80, tt.height) + if tt.viewChat { + model.currentView = viewChat + model.chat.loading = false + model.chat, _ = model.chat.Update(model.childWindowSizeMsg()) + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.chat, model.chat.chatStatus = &chat, chat.Status + model.chat.messages = overflowingMessages(tt.messageCount) + model.chat.rebuildBlocks() + require.LessOrEqual(t, strings.Count(model.View(), "\n")+1, tt.height) + return + } + if tt.wantChatHeight >= 0 { + require.Equal(t, tt.wantChatHeight, model.chat.height) + } + if tt.wantViewportHeight >= 0 { + require.Equal(t, tt.wantViewportHeight, model.chat.viewport.Height) + } + }) + } + }) + t.Run("WrappedComposerFitsTerminal", func(t *testing.T) { + t.Parallel() + model := applyWindowSize(t, newTestTUIModel(), 40, 18) + model.currentView = viewChat + model.chat.loading = false + model.chat, _ = model.chat.Update(model.childWindowSizeMsg()) + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.chat = &chat + model.chat.chatStatus = chat.Status + model.chat.messages = overflowingMessages(18) + model.chat.rebuildBlocks() + initialViewportHeight := model.chat.viewport.Height + model.chat.composer.SetValue(strings.Repeat("wrapped input ", 14)) + model.chat.recalcViewportHeight() + model.chat.syncViewportContent() + view := plainText(model.View()) + lines := strings.Split(view, "\n") + require.LessOrEqual(t, model.chat.viewport.Height, initialViewportHeight) + require.LessOrEqual(t, len(lines), 18) + require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1])) + }) + t.Run("ViewShowsSingleStatusBarAndComposerDivider", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.loading = false + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 60, Height: 14}) + chat := testChat(codersdk.ChatStatusWaiting) + model.chat = &chat + model.chatStatus = chat.Status + model.messages = []codersdk.ChatMessage{ + testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}), + } + model.rebuildBlocks() + view := plainText(model.View()) + require.NotContains(t, view, "Status: waiting") + require.Equal(t, 1, strings.Count(view, "waiting")) + lines := strings.Split(view, "\n") + composerLine := -1 + for i, line := range lines { + if strings.Contains(line, "> ") { + composerLine = i + break + } + } + require.Greater(t, composerLine, 1) + require.Contains(t, lines[composerLine-1], "────") + }) + t.Run("ScrollNavigation", func(t *testing.T) { + t.Parallel() + type yOffsetCheck int + const ( + ySkip yOffsetCheck = iota + yLess + yGreater + yEqual + yHalfUp + yHalfDown + ) + const skip = -1 + tests := []struct { + name string + preKeys []tea.KeyType + key tea.KeyType + yCheck yOffsetCheck + wantAutoFollow int + wantBeforeBottom int + wantAfterBottom int + wantBeforeYOffset int + wantAfterYOffset int + }{ + {"ScrollUpDecreasesYOffset", nil, tea.KeyUp, yLess, 0, skip, skip, skip, skip}, + {"ScrollDownIncreasesYOffset", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, skip, skip, skip, skip, skip}, + {"ScrollUpAtTopIsNoOp", []tea.KeyType{tea.KeyHome}, tea.KeyUp, yEqual, skip, skip, skip, 0, skip}, + {"ScrollDownAtBottomReEnablesAutoFollow", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, 1, 0, 1, skip, skip}, + {"PageUpScrollsHalfViewport", nil, tea.KeyPgUp, yHalfUp, 0, skip, skip, skip, skip}, + {"PageDownScrollsHalfViewport", []tea.KeyType{tea.KeyPgUp}, tea.KeyPgDown, yHalfDown, skip, skip, skip, skip, skip}, + {"HomeJumpsToTop", nil, tea.KeyHome, ySkip, 0, skip, skip, skip, 0}, + {"EndJumpsToBottomAndEnablesAutoFollow", []tea.KeyType{tea.KeyHome}, tea.KeyEnd, ySkip, 1, 0, 1, skip, skip}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + before := scrollableModel(t, tt.preKeys...) + after := mustChatViewUpdate(t, before, tea.KeyMsg{Type: tt.key}) + assertScrollNavigationCase(t, before, after, tt.wantBeforeYOffset, tt.wantAfterYOffset, tt.wantAutoFollow, tt.wantBeforeBottom, tt.wantAfterBottom, int(tt.yCheck)) + }) + } + }) + t.Run("AutoFollowOnContentUpdates", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + preKeys []tea.KeyType + messageID int64 + wantAutoFollow bool + wantAtBottom bool + wantPreserveYOffset bool + }{ + {"SetContentPreservesScrollPosition", []tea.KeyType{tea.KeyUp}, 1001, false, false, true}, + {"NewMessageAutoFollowsWhenAtBottom", nil, 1002, true, true, false}, + {"NewMessageDoesNotAutoFollowWhenScrolledUp", []tea.KeyType{tea.KeyUp}, 1003, false, false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + before := scrollableModel(t, tt.preKeys...) + after := updateView(before, streamMessage(tt.messageID)) + require.Equal(t, tt.wantAutoFollow, after.autoFollow) + require.Equal(t, tt.wantAtBottom, after.viewport.AtBottom()) + if tt.wantPreserveYOffset { + require.Equal(t, before.viewport.YOffset, after.viewport.YOffset) + return + } + require.GreaterOrEqual(t, after.viewport.YOffset, before.viewport.YOffset) + }) + } + }) + t.Run("StreamingAutoFollows", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10}) + model = updateView(model, chatHistoryMsg{messages: overflowingMessages(10)}) + before := model.viewport.YOffset + model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("hello world ", 20))}) + model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("more text ", 20))}) + require.True(t, model.autoFollow) + require.True(t, model.viewport.AtBottom()) + require.GreaterOrEqual(t, model.viewport.YOffset, before) + }) + }) + t.Run("ChatView/StatePersistence", func(t *testing.T) { + t.Parallel() + t.Run("ComposerTextSurvivesOverlayToggle", func(t *testing.T) { + t.Parallel() + model := newTestTUIModel() + model.currentView = viewChat + model.chat.loading = false + catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: []codersdk.ChatModel{{ID: uuid.New().String(), Provider: "provider", Model: "model-a", DisplayName: "Model A"}}, + }}} + model.catalog = &catalog + model.chat.modelPickerFlat = catalog.Providers[0].Models + model.chat.composer.SetValue("keep this draft") + updatedModel, cmd := model.Update(toggleModelPickerMsg{}) + model = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, "keep this draft", model.chat.composer.Value()) + updatedModel, cmd = model.Update(toggleModelPickerMsg{}) + model = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, "keep this draft", model.chat.composer.Value()) + }) + + t.Run("ComposerTextSurvivesFocusSwitch", func(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.composer.SetValue("keep this draft") + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab}) + require.False(t, updated.composerFocused) + require.Equal(t, "keep this draft", updated.composer.Value()) + + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyTab}) + require.True(t, updated.composerFocused) + require.Equal(t, "keep this draft", updated.composer.Value()) + }) + + t.Run("ViewportScrollSurvivesOverlayToggle", func(t *testing.T) { + t.Parallel() + model := newTestTUIModel() + model.currentView = viewChat + model.chat.loading = false + updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10}) + model = mustTUIModel(t, updatedModel, cmd) + chat := testChat(codersdk.ChatStatusCompleted) + model.chat.setChat(chat) + model.chat.messages = overflowingMessages(10) + model.chat.gitChanges = []codersdk.ChatGitChange{} + diff := codersdk.ChatDiffContents{ChatID: chat.ID, Diff: "diff --git a/file b/file"} + model.chat.diffContents = &diff + model.chat.rebuildBlocks() + model.chat.composerFocused = false + (&model.chat).syncViewportContent() + model.chat.viewport.GotoBottom() + updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyUp}) + model = mustTUIModel(t, updatedModel, cmd) + require.False(t, model.chat.viewport.AtBottom()) + yBefore := model.chat.viewport.YOffset + updatedModel, cmd = model.Update(toggleDiffDrawerMsg{}) + model = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayDiffDrawer, model.overlay) + require.Equal(t, yBefore, model.chat.viewport.YOffset) + + updatedModel, cmd = model.Update(toggleDiffDrawerMsg{}) + model = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayNone, model.overlay) + require.Equal(t, yBefore, model.chat.viewport.YOffset) + }) + + t.Run("SelectedModelSurvivesPickerReopen", func(t *testing.T) { + t.Parallel() + firstModelID := "provider:model-a" + secondModelID := "provider:model-b" + catalog := codersdk.ChatModelsResponse{ + Providers: []codersdk.ChatModelProvider{{ + Provider: "provider", + Available: true, + Models: []codersdk.ChatModel{ + { + ID: firstModelID, + Provider: "provider", + Model: "model-a", + DisplayName: "Model A", + }, + { + ID: secondModelID, + Provider: "provider", + Model: "model-b", + DisplayName: "Model B", + }, + }, + }}, + } + + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + + updatedModel, cmd := model.Update(modelsListedMsg{catalog: catalog}) + updated := mustTUIModel(t, updatedModel, cmd) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, 1, updated.chat.modelPickerCursor) + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + require.NotNil(t, updated.chat.modelOverride) + require.NotNil(t, updated.modelOverride) + require.Equal(t, secondModelID, *updated.chat.modelOverride) + require.Equal(t, secondModelID, *updated.modelOverride) + + updatedModel, cmd = updated.Update(toggleModelPickerMsg{}) + updated = mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayModelPicker, updated.overlay) + require.Equal(t, 1, updated.chat.modelPickerCursor) + require.NotNil(t, updated.chat.modelOverride) + require.Equal(t, secondModelID, *updated.chat.modelOverride) + }) + }) + + t.Run("ChatView/ChatLifecycle", func(t *testing.T) { + t.Parallel() + t.Run("StreamingChatSwitchBackToList", func(t *testing.T) { + t.Parallel() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.currentView = viewChat + chat := testChat(codersdk.ChatStatusRunning) + model.chat.chat = &chat + model.chat.chatStatus = codersdk.ChatStatusRunning + model.chat.streaming = true + model.chat.streamCloser = io.NopCloser(strings.NewReader("stream")) + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.True(t, updated.list.loading) + require.False(t, updated.chat.streaming) + require.Nil(t, updated.chat.streamCloser) + require.NotNil(t, cmd) + }) + + t.Run("ReOpenSameChatAfterEsc", func(t *testing.T) { + t.Parallel() + chatID := uuid.New() + model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) + model.width = 100 + model.height = 40 + + updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: chatID}) + updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.Len(t, mustBatchMsg(t, cmd), 3) + + openedChat := testChat(codersdk.ChatStatusCompleted) + openedChat.ID = chatID + updated.chat.chat = &openedChat + updated.chat.loading = false + updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale message"})} + updated.chat.composer.SetValue("stale draft") + + updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewList, updated.currentView) + require.True(t, updated.list.loading) + + updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: chatID}) + updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd) + require.Equal(t, viewChat, updated.currentView) + require.True(t, updated.chat.loading) + require.Nil(t, updated.chat.chat) + require.Empty(t, updated.chat.messages) + require.Empty(t, updated.chat.composer.Value()) + require.Len(t, mustBatchMsg(t, cmd), 3) + }) + }) + + t.Run("ChatView/TranscriptSync", func(t *testing.T) { + t.Parallel() + newTranscriptModel := func() chatViewModel { + model := newTestChatViewModel(nil) + model.width = 80 + model.blocks = []chatBlock{ + {kind: blockText, role: codersdk.ChatMessageRoleUser, text: "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi"}, + {kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"}, + } + model.selectedBlock = 0 + model.composerFocused = false + return model + } + + t.Run("TranscriptRefreshRules", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(m *chatViewModel) + expectNew bool + }{ + {"RepeatedViewNoRefresh", func(m *chatViewModel) {}, false}, + {"BlockChange", func(m *chatViewModel) { + m.blocks = append(m.blocks, chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "new block"}) + }, true}, + {"SelectionChange", func(m *chatViewModel) { m.selectedBlock = 1 }, true}, + {"WidthChange", func(m *chatViewModel) { m.width = 60 }, true}, + {"ComposerFocusChange", func(m *chatViewModel) { m.composerFocused = true }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + model := newTranscriptModel() + (&model).syncViewportContent() + firstTranscript := model.lastTranscript + require.NotEmpty(t, firstTranscript) + + tt.mutate(&model) + (&model).syncViewportContent() + if tt.expectNew { + require.NotEqual(t, firstTranscript, model.lastTranscript) + } else { + require.Equal(t, firstTranscript, model.lastTranscript) + } + }) + } + }) + }) + + t.Run("SpinnerTickOnlyRefreshesWhenVisible", func(t *testing.T) { + t.Parallel() + + model := newTestChatViewModel(nil) + model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10}) + chat := testChat(codersdk.ChatStatusRunning) + model.chat = &chat + model.chatStatus = chat.Status + model.messages = overflowingMessages(18) + model.rebuildBlocks() + + visibleTranscript := model.lastTranscript + updated, cmd := model.Update(model.spinner.Tick()) + require.NotNil(t, cmd) + require.NotEqual(t, visibleTranscript, updated.lastTranscript) + + updated.viewport.LineUp(3) + updated.autoFollow = false + require.False(t, updated.viewport.AtBottom()) + + hiddenTranscript := updated.lastTranscript + hiddenYOffset := updated.viewport.YOffset + updated, cmd = updated.Update(updated.spinner.Tick()) + require.NotNil(t, cmd) + require.Equal(t, hiddenTranscript, updated.lastTranscript) + require.Equal(t, hiddenYOffset, updated.viewport.YOffset) + }) + + t.Run("AskUserQuestion", func(t *testing.T) { + t.Parallel() + mustAskArgs := func(t testing.TB, questions ...parsedAskQuestion) string { + t.Helper() + payloadQuestions := make([]map[string]any, 0, len(questions)) + for _, question := range questions { + options := make([]map[string]string, 0, len(question.Options)) + for _, option := range question.Options { + options = append(options, map[string]string{ + "label": option.Label, + "value": option.Value, + }) + } + payloadQuestions = append(payloadQuestions, map[string]any{ + "header": question.Header, + "question": question.Question, + "options": options, + }) + } + output, err := json.Marshal(map[string]any{"questions": payloadQuestions}) + require.NoError(t, err) + return string(output) + } + askToolCall := func(t testing.TB, toolCallID string, questions ...parsedAskQuestion) codersdk.ChatStreamToolCall { + t.Helper() + return codersdk.ChatStreamToolCall{ + ToolCallID: toolCallID, + ToolName: "ask_user_question", + Args: mustAskArgs(t, questions...), + } + } + message := func(parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { + return codersdk.ChatMessage{Content: parts} + } + toolCallPart := func(toolCallID, toolName, args string) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: toolCallID, + ToolName: toolName, + Args: rawJSON(args), + } + } + toolResultPart := func(toolCallID, toolName, result string) codersdk.ChatMessagePart { + return codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolResult, + ToolCallID: toolCallID, + ToolName: toolName, + Result: rawJSON(result), + } + } + firstQuestion := parsedAskQuestion{ + Header: "Review plan", + Question: "What should happen next?", + Options: []parsedAskOption{ + {Label: "Approve", Value: "approve"}, + {Label: "Reject", Value: "reject"}, + }, + } + secondQuestion := parsedAskQuestion{ + Header: "Reason", + Question: "Why?", + Options: []parsedAskOption{ + {Label: "Speed", Value: "speed"}, + {Label: "Quality", Value: "quality"}, + }, + } + + t.Run("ParseToolCall", func(t *testing.T) { + t.Parallel() + + t.Run("ValidJSONWithOptions", func(t *testing.T) { + t.Parallel() + + state, err := parseAskUserQuestionToolCall(askToolCall(t, "tool-1", firstQuestion, secondQuestion)) + require.NoError(t, err) + require.Equal(t, "tool-1", state.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions) + require.Empty(t, state.Answers) + require.Zero(t, state.CurrentIndex) + require.Zero(t, state.OptionCursor) + }) + + t.Run("EmptyOrMissingQuestionsReturnsError", func(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + args string + }{ + {name: "MissingQuestions", args: `{}`}, + {name: "EmptyQuestions", args: `{"questions":[]}`}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{ + ToolCallID: "tool-1", + ToolName: "ask_user_question", + Args: tt.args, + }) + require.Nil(t, state) + require.ErrorContains(t, err, "at least one question") + }) + } + }) + + t.Run("MalformedJSONReturnsError", func(t *testing.T) { + t.Parallel() + + state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{ + ToolCallID: "tool-1", + ToolName: "ask_user_question", + Args: `{"questions":[`, + }) + require.Nil(t, state) + require.ErrorContains(t, err, "parse ask_user_question args") + }) + }) + + t.Run("BuildToolResult", func(t *testing.T) { + t.Parallel() + + t.Run("AnswersMarshalToJSON", func(t *testing.T) { + t.Parallel() + + output, err := buildAskUserQuestionToolResult(&askUserQuestionState{ + Answers: []askQuestionAnswer{{ + Header: firstQuestion.Header, + Question: firstQuestion.Question, + Answer: "approve", + OptionLabel: "Approve", + Freeform: false, + }}, + }) + require.NoError(t, err) + require.JSONEq(t, `{"answers":[{"header":"Review plan","question":"What should happen next?","answer":"approve","option_label":"Approve","freeform":false}]}`, string(output)) + }) + + t.Run("NoAnswersUsesEmptyArray", func(t *testing.T) { + t.Parallel() + + output, err := buildAskUserQuestionToolResult(&askUserQuestionState{}) + require.NoError(t, err) + require.JSONEq(t, `{"answers":[]}`, string(output)) + }) + }) + + t.Run("FindPending", func(t *testing.T) { + t.Parallel() + + t.Run("NoMessagesReturnsNil", func(t *testing.T) { + t.Parallel() + + state, err := findPendingAskUserQuestion(nil) + require.NoError(t, err) + require.Nil(t, state) + }) + + t.Run("ServerToolResultStillReturnsPendingState", func(t *testing.T) { + t.Parallel() + + messages := []codersdk.ChatMessage{ + message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), + message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), + } + state, err := findPendingAskUserQuestion(messages) + require.NoError(t, err) + require.NotNil(t, state) + require.Equal(t, "tool-1", state.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion}, state.Questions) + }) + + t.Run("UserAnsweredToolCallReturnsNil", func(t *testing.T) { + t.Parallel() + + messages := []codersdk.ChatMessage{ + message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), + message(toolResultPart("tool-1", "ask_user_question", `{"answers":[{"answer":"approve"}]}`)), + } + state, err := findPendingAskUserQuestion(messages) + require.NoError(t, err) + require.Nil(t, state) + }) + + t.Run("UnmatchedToolCallReturnsParsedState", func(t *testing.T) { + t.Parallel() + + messages := []codersdk.ChatMessage{ + message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion))), + message(codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"}), + } + state, err := findPendingAskUserQuestion(messages) + require.NoError(t, err) + require.NotNil(t, state) + require.Equal(t, "tool-1", state.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions) + }) + + t.Run("NonAskUserQuestionToolCallReturnsNil", func(t *testing.T) { + t.Parallel() + + messages := []codersdk.ChatMessage{ + message(toolCallPart("tool-1", "search_docs", `{"query":"overlay"}`)), + } + state, err := findPendingAskUserQuestion(messages) + require.NoError(t, err) + require.Nil(t, state) + }) + }) + + t.Run("HandleStreamEventActionRequired", func(t *testing.T) { + t.Parallel() + + t.Run("AskUserQuestionShowsOverlay", func(t *testing.T) { + t.Parallel() + + model := newTestChatViewModel(nil) + updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeActionRequired, + ActionRequired: &codersdk.ChatStreamActionRequired{ + ToolCalls: []codersdk.ChatStreamToolCall{askToolCall(t, "tool-1", firstQuestion)}, + }, + }) + require.NotNil(t, updated.pendingAskUserQuestion) + require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions) + showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) + require.True(t, ok) + require.Same(t, updated.pendingAskUserQuestion, showMsg.state) + }) + + t.Run("NonAskUserQuestionToolCallIsIgnored", func(t *testing.T) { + t.Parallel() + + model := newTestChatViewModel(nil) + updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeActionRequired, + ActionRequired: &codersdk.ChatStreamActionRequired{ + ToolCalls: []codersdk.ChatStreamToolCall{{ + ToolCallID: "tool-1", + ToolName: "search_docs", + Args: `{"query":"overlay"}`, + }}, + }, + }) + require.Nil(t, updated.pendingAskUserQuestion) + require.Nil(t, cmd) + }) + + t.Run("MalformedArgsReturnErrorEvent", func(t *testing.T) { + t.Parallel() + + model := newTestChatViewModel(nil) + model.activeChatID = uuid.New() + model.chatGeneration = 7 + updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeActionRequired, + ActionRequired: &codersdk.ChatStreamActionRequired{ + ToolCalls: []codersdk.ChatStreamToolCall{{ + ToolCallID: "tool-1", + ToolName: "ask_user_question", + Args: `{"questions":[`, + }}, + }, + }) + require.Nil(t, updated.pendingAskUserQuestion) + streamMsg, ok := mustMsg(t, cmd).(chatStreamEventMsg) + require.True(t, ok) + require.Equal(t, uint64(7), streamMsg.generation) + require.Equal(t, model.activeChatID, streamMsg.chatID) + require.Equal(t, codersdk.ChatStreamEventTypeError, streamMsg.event.Type) + require.NotNil(t, streamMsg.event.Error) + require.Contains(t, streamMsg.event.Error.Message, "failed to parse ask_user_question") + + updated = mustChatViewUpdate(t, updated, streamMsg) + require.EqualError(t, updated.err, "stream error: "+streamMsg.event.Error.Message) + }) + }) + + t.Run("HandleStreamEventStatusRequiresAction", func(t *testing.T) { + t.Parallel() + + t.Run("RecoversFromMessages", func(t *testing.T) { + t.Parallel() + + chat := testChat(codersdk.ChatStatusRunning) + model := newTestChatViewModel(nil) + model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status + model.messages = []codersdk.ChatMessage{ + message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), + message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))), + } + + updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: chat.ID, + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction}, + }) + require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus) + require.NotNil(t, updated.pendingAskUserQuestion) + require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions) + showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) + require.True(t, ok) + require.Same(t, updated.pendingAskUserQuestion, showMsg.state) + }) + + t.Run("RecoversFromAccumulatorBeforeFinalMessage", func(t *testing.T) { + t.Parallel() + + chat := testChat(codersdk.ChatStatusRunning) + model := newTestChatViewModel(nil) + model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status + model.accumulator.parts = []codersdk.ChatMessagePart{ + toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion)), + } + model.accumulator.pending = true + + updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{ + Type: codersdk.ChatStreamEventTypeStatus, + ChatID: chat.ID, + Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction}, + }) + require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus) + require.NotNil(t, updated.pendingAskUserQuestion) + require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID) + require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, updated.pendingAskUserQuestion.Questions) + showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg) + require.True(t, ok) + require.Same(t, updated.pendingAskUserQuestion, showMsg.state) + }) + }) + + t.Run("OverlayLifecycle", func(t *testing.T) { + t.Parallel() + + newOverlayState := func() *askUserQuestionState { + return newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + } + + t.Run("ShowOpensOverlay", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + model := newTestTUIModel() + model.currentView = viewChat + + updatedModel, cmd := model.Update(showAskUserQuestionMsg{state: state}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayAskUserQuestion, updated.overlay) + require.Same(t, state, updated.chat.pendingAskUserQuestion) + }) + + t.Run("HideClosesOverlay", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + + updatedModel, cmd := model.Update(hideAskUserQuestionMsg{}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + require.Same(t, state, updated.chat.pendingAskUserQuestion) + }) + + t.Run("EscapeDoesNotCloseOverlay", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayAskUserQuestion, updated.overlay) + require.Same(t, state, updated.chat.pendingAskUserQuestion) + }) + + t.Run("SuccessfulSubmitClearsOverlay", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + model.chat.activeChatID = uuid.New() + model.chat.chatGeneration = 11 + + updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ + generation: 11, + chatID: model.chat.activeChatID, + }) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayNone, updated.overlay) + require.Nil(t, updated.chat.pendingAskUserQuestion) + }) + + t.Run("SubmitErrorKeepsOverlayOpen", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + state.Submitting = true + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + model.chat.activeChatID = uuid.New() + model.chat.chatGeneration = 11 + + updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ + generation: 11, + chatID: model.chat.activeChatID, + err: xerrors.New("submit failed"), + }) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayAskUserQuestion, updated.overlay) + require.NotNil(t, updated.chat.pendingAskUserQuestion) + require.False(t, updated.chat.pendingAskUserQuestion.Submitting) + require.EqualError(t, updated.chat.pendingAskUserQuestion.Error, "submit failed") + }) + + t.Run("StaleSubmitIsIgnored", func(t *testing.T) { + t.Parallel() + + state := newOverlayState() + state.Submitting = true + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + model.chat.activeChatID = uuid.New() + model.chat.chatGeneration = 11 + + updatedModel, cmd := model.Update(toolResultsSubmittedMsg{ + generation: 10, + chatID: model.chat.activeChatID, + }) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayAskUserQuestion, updated.overlay) + require.Same(t, state, updated.chat.pendingAskUserQuestion) + require.True(t, updated.chat.pendingAskUserQuestion.Submitting) + require.NoError(t, updated.chat.pendingAskUserQuestion.Error) + }) + }) + + t.Run("KeyHandling", func(t *testing.T) { + t.Parallel() + + t.Run("UpAndDownNavigateOptions", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + model := newTestTUIModel() + model.chat.pendingAskUserQuestion = state + + require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyDown})) + require.Equal(t, 1, state.OptionCursor) + require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp})) + require.Zero(t, state.OptionCursor) + require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp})) + require.Equal(t, len(firstQuestion.Options), state.OptionCursor) + }) + + t.Run("EnterOnOptionRecordsAnswerAndAdvances", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion}) + state.OptionCursor = 1 + model := newTestTUIModel() + model.chat.pendingAskUserQuestion = state + + cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter}) + require.Nil(t, cmd) + require.Len(t, state.Answers, 1) + require.Equal(t, askQuestionAnswer{ + Header: firstQuestion.Header, + Question: firstQuestion.Question, + Answer: "reject", + OptionLabel: "Reject", + Freeform: false, + }, state.Answers[0]) + require.Equal(t, 1, state.CurrentIndex) + require.Zero(t, state.OptionCursor) + }) + + t.Run("EnterOnOtherEntersFreeformMode", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + state.OptionCursor = len(firstQuestion.Options) + model := newTestTUIModel() + model.chat.pendingAskUserQuestion = state + + cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter}) + require.Nil(t, cmd) + require.True(t, state.OtherMode) + require.Empty(t, state.OtherInput.Value()) + }) + + t.Run("EscapeInFreeformModeExitsOnlyInput", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + state.OtherMode = true + state.OtherInput.Focus() + state.OtherInput.SetValue("typed answer") + model := newTestTUIModel() + model.currentView = viewChat + model.overlay = overlayAskUserQuestion + model.chat.pendingAskUserQuestion = state + + updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + updated := mustTUIModel(t, updatedModel, cmd) + require.Equal(t, overlayAskUserQuestion, updated.overlay) + require.NotNil(t, updated.chat.pendingAskUserQuestion) + require.False(t, updated.chat.pendingAskUserQuestion.OtherMode) + require.Equal(t, "typed answer", updated.chat.pendingAskUserQuestion.OtherInput.Value()) + }) + + t.Run("LeftOrHMovesBackToPreviousQuestion", func(t *testing.T) { + t.Parallel() + + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion}) + state.CurrentIndex = 1 + state.OptionCursor = 1 + state.Error = xerrors.New("temporary error") + state.Answers = []askQuestionAnswer{{ + Header: firstQuestion.Header, + Question: firstQuestion.Question, + Answer: "approve", + OptionLabel: "Approve", + Freeform: false, + }} + model := newTestTUIModel() + model.chat.pendingAskUserQuestion = state + + cmd := model.handleAskUserQuestionKey(keyRunes("h")) + require.Nil(t, cmd) + require.Zero(t, state.CurrentIndex) + require.Zero(t, state.OptionCursor) + require.False(t, state.OtherMode) + require.Nil(t, state.Error) + require.Empty(t, state.Answers) + }) + }) + + t.Run("RecordAskAnswer", func(t *testing.T) { + t.Parallel() + + model := newExpChatsTUIModel(context.Background(), failingExperimentalClient(), nil, nil, nil, uuid.Nil) + model.chat.activeChatID = uuid.New() + model.chat.chatGeneration = 4 + state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + state.OtherMode = true + state.OtherInput.Focus() + state.OtherInput.SetValue("custom answer") + model.chat.pendingAskUserQuestion = state + + cmd := model.recordAskAnswer("custom answer", "", true) + require.NotNil(t, cmd) + require.True(t, state.Submitting) + require.Len(t, state.Answers, 1) + require.Equal(t, askQuestionAnswer{ + Header: firstQuestion.Header, + Question: firstQuestion.Question, + Answer: "custom answer", + Freeform: true, + }, state.Answers[0]) + require.False(t, state.OtherMode) + require.Empty(t, state.OtherInput.Value()) + }) + + t.Run("ComposerBlocksEnterWhileQuestionPending", func(t *testing.T) { + t.Parallel() + + baseline := newTestChatViewModel(failingExperimentalClient()) + baseline.draft = true + baseline.loading = false + baseline.composer.SetValue("send this") + + updated, cmd := baseline.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.NotNil(t, cmd) + require.True(t, updated.creatingChat) + require.Empty(t, updated.composer.Value()) + + blocked := newTestChatViewModel(failingExperimentalClient()) + blocked.draft = true + blocked.loading = false + blocked.composer.SetValue("send this") + blocked.pendingAskUserQuestion = newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion}) + + updated, cmd = blocked.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.Nil(t, cmd) + require.False(t, updated.creatingChat) + require.Equal(t, "send this", updated.composer.Value()) + require.Empty(t, updated.pendingComposerText) + }) + }) + + t.Run("ChatList", func(t *testing.T) { + t.Parallel() + newChat := func(status codersdk.ChatStatus, title string, parent *uuid.UUID) codersdk.Chat { + chat := testChat(status) + chat.Title, chat.ParentChatID = title, parent + return chat + } + newList := func(chats ...codersdk.Chat) chatListModel { + model := newReadyChatListModel() + model.chats = chats + return model + } + mustUpdate := func(t testing.TB, model chatListModel, msg tea.Msg) chatListModel { + t.Helper() + updated, cmd := model.Update(msg) + require.Nil(t, cmd) + return updated + } + requireRows := func(t testing.TB, rows []chatDisplayRow, wantChats []codersdk.Chat, wantDepths ...int) { + t.Helper() + require.Len(t, rows, len(wantChats)) + for i, want := range wantChats { + require.Equal(t, want.ID, rows[i].chat.ID) + require.Equal(t, wantDepths[i], rows[i].depth) + } + } + t.Run("ChatsListedUpdatesState", func(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + msg chatsListedMsg + wantChats int + wantErr string + }{ + {name: "StoresChats", msg: chatsListedMsg{chats: []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusCompleted)}}, wantChats: 2}, + {name: "StoresErr", msg: chatsListedMsg{err: xerrors.New("list failed")}, wantErr: "list failed"}, + } { + updated, cmd := newChatListModel(newTUIStyles()).Update(tt.msg) + require.Nilf(t, cmd, tt.name) + require.Falsef(t, updated.loading, tt.name) + require.Lenf(t, updated.chats, tt.wantChats, tt.name) + if tt.wantErr == "" { + require.NoErrorf(t, updated.err, tt.name) + continue + } + require.EqualErrorf(t, updated.err, tt.wantErr, tt.name) + } + }) + t.Run("ParentExpansionAndCollapse", func(t *testing.T) { + t.Parallel() + parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil) + childA := newChat(codersdk.ChatStatusWaiting, "Subagent A", &parent.ID) + childB := newChat(codersdk.ChatStatusPending, "Subagent B", &parent.ID) + root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil) + model := newList(parent, childA, childB, root) + requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0) + output := plainText(model.View()) + require.Contains(t, output, "▶ Parent chat") + require.Contains(t, output, "(2 subagents)") + require.NotContains(t, output, childA.Title) + require.NotContains(t, output, childB.Title) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) + require.True(t, model.expanded[parent.ID]) + requireRows(t, model.displayRows(), []codersdk.Chat{parent, childA, childB, root}, 0, 1, 1, 0) + model = mustUpdate(t, model, keyRunes("x")) + require.False(t, model.expanded[parent.ID]) + requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0) + model = mustUpdate(t, model, keyRunes("x")) + require.True(t, model.expanded[parent.ID]) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) + require.False(t, model.expanded[parent.ID]) + require.Zero(t, model.cursor) + }) + t.Run("NestedExpansionNavigationAndOpenSelectedChat", func(t *testing.T) { + t.Parallel() + parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil) + child := newChat(codersdk.ChatStatusWaiting, "Child subagent", &parent.ID) + grandchild := newChat(codersdk.ChatStatusPending, "Grandchild subagent", &child.ID) + root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil) + model := newList(parent, child, grandchild, root) + model.width, model.height = 100, 10 + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) + require.True(t, model.expanded[parent.ID]) + requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, root}, 0, 1, 0) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) + selected := model.selectedChat() + require.NotNil(t, selected) + require.Equal(t, child.ID, selected.ID) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight}) + require.True(t, model.expanded[child.ID]) + requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, grandchild, root}, 0, 1, 2, 0) + require.Contains(t, plainText(model.View()), "Grandchild subagent") + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) + selected = model.selectedChat() + require.NotNil(t, selected) + require.Equal(t, grandchild.ID, selected.ID) + model, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + openMsg, ok := mustMsg(t, cmd).(openSelectedChatMsg) + require.True(t, ok) + require.Equal(t, grandchild.ID, openMsg.chatID) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) + require.False(t, model.expanded[child.ID]) + require.Equal(t, child.ID, model.selectedChat().ID) + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft}) + require.False(t, model.expanded[parent.ID]) + require.Equal(t, parent.ID, model.selectedChat().ID) + }) + t.Run("SearchIncludesVisibleAncestorChain", func(t *testing.T) { + t.Parallel() + for _, depth := range []int{1, 2} { + model := newReadyChatListModel() + chain := make([]codersdk.Chat, 0, depth+1) + wantDepths := make([]int, 0, depth+1) + var parentID *uuid.UUID + for d := 0; d <= depth; d++ { + title := "Root chat" + if d > 0 { + title = fmt.Sprintf("Subagent depth %d", d) + } + if d == depth { + title += " needle" + } + chain = append(chain, newChat(codersdk.ChatStatusWaiting, title, parentID)) + parentID = &chain[len(chain)-1].ID + wantDepths = append(wantDepths, d) + } + model.chats = append([]codersdk.Chat{}, chain...) + model.chats = append(model.chats, newChat(codersdk.ChatStatusCompleted, "Other root", nil)) + model.search.SetValue("needle") + rows := model.displayRows() + requireRows(t, rows, chain, wantDepths...) + for i, row := range rows { + require.Equalf(t, i < depth, row.isExpanded, "depth=%d row=%d", depth, i) + } + } + }) + t.Run("NavigationKeysMoveCursorWithinBounds", func(t *testing.T) { + t.Parallel() + chats := []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusPending), testChat(codersdk.ChatStatusCompleted)} + for _, tt := range []struct { + name string + key tea.KeyMsg + want int + }{ + {name: "Up", key: tea.KeyMsg{Type: tea.KeyUp}, want: 0}, + {name: "Down", key: tea.KeyMsg{Type: tea.KeyDown}, want: 2}, + {name: "J", key: keyRunes("j"), want: 2}, + {name: "K", key: keyRunes("k"), want: 0}, + } { + model := newList(chats...) + model.cursor = 1 + model = mustUpdate(t, model, tt.key) + require.Equalf(t, tt.want, model.cursor, tt.name) + model = mustUpdate(t, model, tt.key) + require.Equalf(t, tt.want, model.cursor, tt.name) + } + }) + t.Run("ViewKeepsSelectedChatVisible", func(t *testing.T) { + t.Parallel() + model := newReadyChatListModel() + model.width, model.height = 80, 8 + for i := range 8 { + model.chats = append(model.chats, newChat(codersdk.ChatStatusWaiting, fmt.Sprintf("chat %02d", i), nil)) + } + for range 6 { + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown}) + } + require.Equal(t, 2, model.offset) + listView := plainText(model.View()) + require.Contains(t, listView, "> chat 06") + require.NotContains(t, listView, "chat 00") + parent := newTestTUIModel() + parent.width, parent.height, parent.list = 80, 8, model + parentView := plainText(parent.View()) + require.Contains(t, parentView, "Coder Chats") + require.Contains(t, parentView, "> chat 06") + for range 5 { + model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyUp}) + } + require.Equal(t, 1, model.offset) + require.Contains(t, plainText(model.View()), "> chat 01") + }) + t.Run("EmptyListDisplaysNoChatsMessage", func(t *testing.T) { + t.Parallel() + updated, cmd := newChatListModel(newTUIStyles()).Update(chatsListedMsg{chats: []codersdk.Chat{}}) + require.Nil(t, cmd) + require.Contains(t, plainText(updated.View()), "No chats yet") + }) + }) +} + +func TestExpAgents_View_LongInputFitsTerminal(t *testing.T) { + t.Parallel() + model := newTestChatViewModel(nil) + model.width, model.height = 80, 24 + model.setComposerWidth() + model.recalcViewportHeight() + model.syncViewportContent() + chat := testChat(codersdk.ChatStatusCompleted) + model.chat = &chat + model.chatStatus = chat.Status + model.messages = overflowingMessages(24) + model.rebuildBlocks() + + defaultViewportHeight := model.viewport.Height + model.composer.SetValue(strings.Repeat("a", 250)) + model.recalcViewportHeight() + model.syncViewportContent() + + view := plainText(model.View()) + lines := strings.Split(view, "\n") + + require.LessOrEqual(t, len(lines), model.height) + require.LessOrEqual(t, model.viewport.Height, defaultViewportHeight) + require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1])) +} + +func mustTUIModel(t testing.TB, model tea.Model, cmd tea.Cmd) expChatsTUIModel { + t.Helper() + updated, ok := model.(expChatsTUIModel) + require.True(t, ok) + require.Nil(t, cmd) + return updated +} + +func mustTUIModelWithCmd(t testing.TB, model tea.Model, cmd tea.Cmd) (expChatsTUIModel, tea.Cmd) { + t.Helper() + updated, ok := model.(expChatsTUIModel) + require.True(t, ok) + return updated, cmd +} + +func mustChatViewUpdate(t testing.TB, model chatViewModel, msg tea.Msg) chatViewModel { + t.Helper() + updated, cmd := model.Update(msg) + require.Nil(t, cmd) + return updated +} + +func mustMsg(t testing.TB, cmd tea.Cmd) tea.Msg { t.Helper(); require.NotNil(t, cmd); return cmd() } + +func mustBatchMsg(t testing.TB, cmd tea.Cmd) tea.BatchMsg { + t.Helper() + batch, ok := mustMsg(t, cmd).(tea.BatchMsg) + require.True(t, ok) + return batch +} + +func assertStreamCase(t testing.TB, model chatViewModel, wantMessages int, wantAccumulatorText, wantAccumulatorArgs string, wantBlockKind chatBlockKind, wantBlockText, wantBlockArgs string, wantUsage *codersdk.ChatMessageUsage) { + t.Helper() + wantPending := wantAccumulatorText != "" || wantAccumulatorArgs != "" + require.Len(t, model.messages, wantMessages) + require.Equal(t, wantPending, model.accumulator.isPending()) + if wantPending { + require.Len(t, model.accumulator.parts, 1) + if wantAccumulatorText != "" { + require.Equal(t, wantAccumulatorText, model.accumulator.parts[0].Text) + } + if wantAccumulatorArgs != "" { + require.Equal(t, wantAccumulatorArgs, string(model.accumulator.parts[0].Args)) + } + } else { + require.Empty(t, model.accumulator.parts) + } + require.Len(t, model.blocks, 1) + require.Equal(t, wantBlockKind, model.blocks[0].kind) + if wantBlockText != "" { + require.Equal(t, wantBlockText, model.blocks[0].text) + } + if wantBlockArgs != "" { + require.Equal(t, wantBlockArgs, model.blocks[0].args) + } + require.Equal(t, wantUsage, model.lastUsage) + require.False(t, model.reconnecting) +} + +func assertScrollNavigationCase(t testing.TB, before chatViewModel, after chatViewModel, wantBeforeYOffset int, wantAfterYOffset int, wantAutoFollow int, wantBeforeBottom int, wantAfterBottom int, yCheck int) { + t.Helper() + if wantAfterYOffset == 0 && wantBeforeYOffset == -1 { + require.NotZero(t, before.viewport.YOffset) + } + if wantBeforeYOffset != -1 { + require.Equal(t, wantBeforeYOffset, before.viewport.YOffset) + } + if wantAfterYOffset != -1 { + require.Equal(t, wantAfterYOffset, after.viewport.YOffset) + } + if wantAutoFollow != -1 { + require.Equal(t, wantAutoFollow == 1, after.autoFollow) + } + if wantBeforeBottom != -1 { + require.Equal(t, wantBeforeBottom == 1, before.viewport.AtBottom()) + } + if wantAfterBottom != -1 { + require.Equal(t, wantAfterBottom == 1, after.viewport.AtBottom()) + } + switch yCheck { + case 1: + require.Less(t, after.viewport.YOffset, before.viewport.YOffset) + case 2: + require.Greater(t, after.viewport.YOffset, before.viewport.YOffset) + case 3: + require.Equal(t, before.viewport.YOffset, after.viewport.YOffset) + case 4: + halfView := before.viewport.Height / 2 + require.InDelta(t, float64(before.viewport.YOffset-halfView), float64(after.viewport.YOffset), 1) + case 5: + halfView := before.viewport.Height / 2 + require.InDelta(t, float64(before.viewport.YOffset+halfView), float64(after.viewport.YOffset), 1) + } +} + +// newTestChatViewModel creates a chatViewModel for reducer tests. +// The returned model has chatGeneration=0, so test messages with +// default generation=0 pass the generation guard. +func newTestChatViewModel(client *codersdk.ExperimentalClient) chatViewModel { + return newChatViewModel(context.Background(), client, nil, nil, uuid.Nil, newTUIStyles()) +} + +func newTestTUIModel() expChatsTUIModel { + return newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil) +} + +func newReadyChatListModel() chatListModel { + model := newChatListModel(newTUIStyles()) + model.loading = false + return model +} + +func newTestExperimentalClient(t testing.TB, handler http.HandlerFunc) *codersdk.ExperimentalClient { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + return codersdk.NewExperimentalClient(codersdk.New(serverURL)) +} + +func overflowingMessages(count int) []codersdk.ChatMessage { + messages := make([]codersdk.ChatMessage, 0, count) + for i := 0; i < count; i++ { + role := codersdk.ChatMessageRoleUser + if i%2 == 1 { + role = codersdk.ChatMessageRoleAssistant + } + messages = append(messages, testMessage(int64(i+1), role, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("message %d %s", i+1, strings.Repeat("content ", 18))})) + } + return messages +} + +func testChat(status codersdk.ChatStatus) codersdk.Chat { + return codersdk.Chat{ID: uuid.New(), Title: "test chat", Status: status, CreatedAt: time.Now(), UpdatedAt: time.Now()} +} + +func testMessage(id int64, role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage { + return codersdk.ChatMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Role: role, Content: parts} +} + +func testQueuedMessage(id int64, parts ...codersdk.ChatMessagePart) codersdk.ChatQueuedMessage { + return codersdk.ChatQueuedMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Content: parts} +} + +func testTextPartEvent(text string) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{ + Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: text}, + }} +} + +func testToolCallDeltaEvent(toolCallID, toolName, delta string) codersdk.ChatStreamEvent { + return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{ + Role: codersdk.ChatMessageRoleAssistant, + Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: toolCallID, ToolName: toolName, ArgsDelta: delta}, + }} +} + +func failingExperimentalClient() *codersdk.ExperimentalClient { + return codersdk.NewExperimentalClient(codersdk.New(&url.URL{})) +} + +func keyRunes(value string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)} } + +func int64Ref(v int64) *int64 { + return &v +} diff --git a/cli/root.go b/cli/root.go index 3d1ad8b97b..34a3fdcb3b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -164,6 +164,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command { r.promptExample(), r.rptyCommand(), r.syncCommand(), + r.agentsCommand(), } }