Compare commits

...

98 Commits

Author SHA1 Message Date
Michael Suchacz a3f9f4c42b fix(cli): make model picker closeable via multiple key patterns 2026-04-10 22:13:20 +00:00
Michael Suchacz a5a1d0df2c fix(cli): route overlay keys before global handlers in TUI 2026-04-10 21:42:30 +00:00
Michael Suchacz 1ccbfc7842 fix(cli): hide empty model picker providers 2026-04-10 21:34:26 +00:00
Michael Suchacz de94b1620c fix(cli): collapse only identical tool events in TUI transcript 2026-04-10 20:11:00 +00:00
Michael Suchacz 8aedc2f0b4 fix(cli): sanitize chat titles and errors before TUI rendering 2026-04-10 20:10:38 +00:00
Michael Suchacz 937f6ce61d fix(cli): retry stream reconnection on transient failures 2026-04-10 19:52:36 +00:00
Michael Suchacz a7d0016fce fix(cli): deduplicate final tool-call parts in stream accumulator 2026-04-10 19:38:37 +00:00
Michael Suchacz 3537f5ac64 fix(cli): stabilize exp agents viewport height on typing 2026-04-10 10:38:00 +00:00
Michael Suchacz ab6b9bbc9c fix(cli): restore exp agents TUI redraw and search escape 2026-04-10 10:06:51 +00:00
Michael Suchacz 36c6ca76d5 test(cli): compact exp agents tests 2026-04-10 09:07:41 +00:00
Michael Suchacz 97f887d92e refactor(cli): reduce exp agents duplication 2026-04-10 09:07:41 +00:00
Michael Suchacz 5a373c5ad0 refactor(cli): extract exp agents chat helpers 2026-04-10 09:07:41 +00:00
Michael Suchacz 0cd9ba0243 refactor(cli): extract exp agents model helpers 2026-04-10 09:07:41 +00:00
Michael Suchacz bca042065e test(cli): merge send/create error handling into data-driven table 2026-04-10 09:07:41 +00:00
Michael Suchacz 991cf2abd2 test(cli): merge success/error twins in message receiving and stream events 2026-04-10 09:07:41 +00:00
Michael Suchacz cdd396ef9a test(cli): replace function-typed tables with data-driven tables 2026-04-10 09:07:41 +00:00
Michael Suchacz b8ca81e5e3 test(cli): flatten wrapper subtests and inline one-use factories 2026-04-10 09:07:41 +00:00
Michael Suchacz d4a65741db test(cli): compact exp_agents_render_test.go 2026-04-10 09:07:41 +00:00
Michael Suchacz 33faaf59ba refactor(cli): trim exp agents list cursor handling 2026-04-10 06:36:40 +00:00
Michael Suchacz af655ce4df refactor(cli): trim exp agents render helpers 2026-04-10 06:36:40 +00:00
Michael Suchacz 52d97a94c3 refactor(cli): inline exp agents command wrappers 2026-04-10 06:36:40 +00:00
Michael Suchacz d83573051b test(cli): consolidate exp agents render test boilerplate 2026-04-10 06:36:40 +00:00
Michael Suchacz 9b1bf8d73d test(cli): remove redundant TestConsumeChatStreamJSON 2026-04-10 06:36:40 +00:00
Michael Suchacz b3884a029f test(cli): trim redundant exp_agents coverage 2026-04-10 06:36:40 +00:00
Michael Suchacz 8cecf43d7c refactor(cli): trim exp agents helper indirection 2026-04-09 21:50:21 +00:00
Michael Suchacz 9e17971b57 test(cli): trim exp agents test coverage 2026-04-09 21:50:21 +00:00
Michael Suchacz f7d0ac1976 test(cli): trim exp agents render coverage 2026-04-09 21:50:21 +00:00
Michael Suchacz 62aef236a1 fix(cli): remove extra chat composer separator 2026-04-09 16:33:09 +00:00
Michael Suchacz 6d7704c18c test(cli): add chat view layout tests 2026-04-09 16:22:55 +00:00
Michael Suchacz 2a9d9d9921 fix(cli): correct chat tui layout 2026-04-09 16:10:54 +00:00
Michael Suchacz f55238a39b fix(cli): sanitize diff content and resolve draft sessions 2026-04-08 20:14:55 +00:00
Michael Suchacz 7abac8208b fix(cli): restart stream after send when disconnected 2026-04-08 19:53:06 +00:00
Michael Suchacz d5549824db fix(cli): sanitize chat rendering and restore tab focus 2026-04-08 19:33:58 +00:00
Michael Suchacz 737e6c89c0 fix(cli): reconnect waiting chats and window model picker 2026-04-08 18:56:48 +00:00
Michael Suchacz 4382d87254 fix(cli): clear error banner on successful message send 2026-04-08 18:28:12 +00:00
Michael Suchacz 1448bfb56b fix(cli): prioritize interrupt over tab and guard diff drawer 2026-04-08 18:11:41 +00:00
Michael Suchacz 1eb7f79251 fix(cli): clear stream accumulator before reconnect 2026-04-08 17:40:36 +00:00
Michael Suchacz dda7699bd9 fix(cli): preserve model overrides and adjacent tool merges 2026-04-08 17:22:30 +00:00
Michael Suchacz 5b339b7006 fix(cli): start stream on empty chat history 2026-04-08 16:46:48 +00:00
Michael Suchacz dd37cff882 fix(cli): accept string chat model overrides 2026-04-08 16:14:33 +00:00
Michael Suchacz dcaec82215 fix(cli): stabilize exp agents tui state 2026-04-08 15:41:50 +00:00
Michael Suchacz f697ca4c91 fix(cli): handle active chat status and nested subagents 2026-04-08 15:13:17 +00:00
Michael Suchacz 99d71e85f7 fix(cli): harden exp agents session lifecycle 2026-04-08 14:46:55 +00:00
Michael Suchacz fe6a044c70 fix(cli): align chat generation guards in tests 2026-04-08 14:46:55 +00:00
Michael Suchacz e113665de8 fix(cli): harden exp agents chat session generations 2026-04-08 14:46:55 +00:00
Michael Suchacz 5c6b5c75c4 test(cli): assert exp agents e2e exits cleanly 2026-04-08 14:46:55 +00:00
Michael Suchacz 49609962a1 fix(cli): handle model picker load failures 2026-04-08 14:46:55 +00:00
Michael Suchacz 2e09db271f test(cli): add chat lifecycle regression coverage 2026-04-08 14:46:55 +00:00
Michael Suchacz fc24c960e7 fix(cli): guard exp agents chat lifecycle 2026-04-08 14:46:54 +00:00
Michael Suchacz 43334dd1ac fix(cli): simplify collapsed tool call label to just ellipsis 2026-04-08 14:46:54 +00:00
Michael Suchacz 04b8cd33c3 test(cli): trim exp agents tests 2026-04-08 14:46:54 +00:00
Michael Suchacz dea3e060b9 refactor(cli): simplify exp agents helpers 2026-04-08 14:46:54 +00:00
Michael Suchacz d55c24bcd9 refactor(cli): simplify exp agents renderer 2026-04-08 14:46:54 +00:00
Michael Suchacz 8d601a04e4 refactor(cli): simplify exp agents helpers 2026-04-08 14:46:54 +00:00
Michael Suchacz 5606b2ddd6 test(cli): trim exp_agents low-value coverage 2026-04-08 14:46:54 +00:00
Michael Suchacz a0663d31f8 test(cli): consolidate exp agent chat tests 2026-04-08 14:46:54 +00:00
Michael Suchacz 6c348fe4b2 test(cli): table-drive exp agents tests 2026-04-08 14:46:54 +00:00
Michael Suchacz 31b9321591 test(cli): consolidate exp agents render tests 2026-04-08 14:46:54 +00:00
Michael Suchacz 9a4e009c05 test(cli): collapse exp agents stream harness 2026-04-08 14:46:54 +00:00
Michael Suchacz 5f442f42c2 test(cli): add exp agents PTY e2e coverage 2026-04-08 14:46:54 +00:00
Michael Suchacz 2cb066d516 fix(cli): collapse consecutive same-name tool calls in TUI rendering 2026-04-08 14:46:54 +00:00
Michael Suchacz 28ef88d985 fix(cli): gracefully render stream errors without collapsing TUI chrome 2026-04-08 14:46:54 +00:00
Michael Suchacz 4fc751aa7e fix(cli): correct exp agents chat viewport sizing 2026-04-08 14:46:54 +00:00
Michael Suchacz 4f52849462 test(cli): fix exp agents UAT expectations 2026-04-08 14:46:54 +00:00
Michael Suchacz 6d1fd2fb78 test(cli): cover exp agents state transitions 2026-04-08 14:46:54 +00:00
Michael Suchacz 3b878463fb test(cli): cover exp agents render edge cases 2026-04-08 14:46:54 +00:00
Michael Suchacz aa1444154a test(cli): cover exp agents edge cases
Add the requested UAT-style model tests for empty states, send validation, model picker bounds, and diff drawer behavior.

The new coverage exposed two TUI bugs, which this commit fixes:
- the model picker did not show an empty-catalog message
- the diff drawer did not render loading or error states while async diff data was pending or failed
2026-04-08 14:46:54 +00:00
Michael Suchacz 194cd113e2 test(cli): cover exp agents error recovery 2026-04-08 14:46:54 +00:00
Michael Suchacz 3855255e04 fix(cli): improve exp agents chat scrolling 2026-04-08 14:46:54 +00:00
Michael Suchacz 9b7be2404e fix(cli): merge tool-call blocks with empty toolID 2026-04-08 14:46:54 +00:00
Michael Suchacz 2fabaf0ab6 fix(cli): nest subagent chats in exp agents list 2026-04-08 14:46:54 +00:00
Michael Suchacz 966b89c00b fix(cli): restore exp agents chat viewport follow 2026-04-08 14:46:54 +00:00
Michael Suchacz 510b7f822b test(cli): cover workspace render summaries 2026-04-08 14:46:54 +00:00
Michael Suchacz bf6912a35d fix(cli): keep selected chat visible 2026-04-08 14:46:54 +00:00
Michael Suchacz eac1a5f5a4 fix(cli): merge tool call results in agents tui 2026-04-08 14:46:54 +00:00
Michael Suchacz 63bfaef26e fix(cli): tidy exp agents tool rendering 2026-04-08 14:46:54 +00:00
Michael Suchacz 828ad21d60 test(cli): fix SelectionStylingDoesNotPoisonCache assertion 2026-04-08 14:46:54 +00:00
Michael Suchacz eeca70a801 test(cli): cover exp agents caching 2026-04-08 14:46:54 +00:00
Michael Suchacz 22227b9477 fix(cli): cache exp agents tui rendering 2026-04-08 14:46:54 +00:00
Michael Suchacz aa2d82c897 fix(cli): prevent OSC background color query in agents TUI 2026-04-08 14:46:54 +00:00
Michael Suchacz d13e3ec573 refactor(cli): rename exp chats tui to exp agents 2026-04-08 14:46:54 +00:00
Michael Suchacz 0a60348500 fix(cli): use alt screen and fix help bar truncation in TUI 2026-04-08 14:46:54 +00:00
Michael Suchacz 45f1544b87 fix(cli): handle Ctrl shortcuts before composer input 2026-04-08 14:46:54 +00:00
Michael Suchacz 4dd40aca9e fix(cli): fix TUI keyboard input bugs 2026-04-08 14:46:54 +00:00
Michael Suchacz 5cbe107ee2 test(cli): add chat tui reducer coverage 2026-04-08 14:46:54 +00:00
Michael Suchacz ba2293a05d test(cli): add exp chats tui render coverage 2026-04-08 14:46:54 +00:00
Michael Suchacz a60ad40866 feat(cli): implement chat TUI renderers 2026-04-08 14:46:54 +00:00
Michael Suchacz 7a21e8aadb feat(cli): implement chat tui chat reducer 2026-04-08 14:46:54 +00:00
Michael Suchacz 013ecbaed6 feat(cli): wire chat tui foundation state 2026-04-08 14:46:54 +00:00
Michael Suchacz f7d3a097be feat(cli): add chats tui foundation 2026-04-08 14:46:54 +00:00
Michael Suchacz d887994345 refactor(cli): migrate exp chats to ExperimentalClient 2026-04-08 14:46:54 +00:00
Michael Suchacz 29c606031d fix(cli): stabilize exp chats tests 2026-04-08 14:46:53 +00:00
Michael Suchacz abe00a6610 fix(cli): fix exp chats test assertions 2026-04-08 14:46:53 +00:00
Michael Suchacz 1efe526682 feat(cli): implement exp chats transcript 2026-04-08 14:46:53 +00:00
Michael Suchacz 883d3eb279 feat(cli): implement exp chats interrupt and diff 2026-04-08 14:46:53 +00:00
Michael Suchacz c23435112e feat(cli): implement exp chats streaming commands 2026-04-08 14:46:53 +00:00
Michael Suchacz a16fd2bd04 feat(cli): implement exp chats subcommands 2026-04-08 14:46:53 +00:00
Michael Suchacz d20eab3460 feat(cli): scaffold exp chats command group with stubs 2026-04-08 14:46:53 +00:00
14 changed files with 6775 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
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
}
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)
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)
}
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
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
}
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
}
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 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}
}
}
+150
View File
@@ -0,0 +1,150 @@
package cli_test
import (
"context"
"os"
"runtime"
"testing"
"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) {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: values,
})
_ = 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
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, seed string) codersdk.Chat {
t.Helper()
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
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}
}
+87
View File
@@ -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 := setupExpAgentsBackend(t)
_ = seedChat(t, ctx, expClient, "alpha nav seed")
_ = seedChat(t, ctx, expClient, "bravo nav seed")
_ = seedChat(t, ctx, expClient, "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: back")
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 := setupExpAgentsBackend(t)
_ = seedChat(t, ctx, expClient, "alpha filter seed")
_ = seedChat(t, ctx, expClient, "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 := setupExpAgentsBackend(t)
chat := seedChat(t, ctx, expClient, "direct open seed")
session := startExpAgentsSession(t, ctx, client, chat.ID.String())
session.expect(ctx, "direct open seed")
session.expect(ctx, "esc: back")
session.esc()
session.expect(ctx, "enter: open")
session.quit()
require.NoError(t, session.wait(ctx))
})
}
+33
View File
@@ -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)
}
+482
View File
@@ -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)))
}
+374
View File
@@ -0,0 +1,374 @@
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
)
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
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,
) expChatsTUIModel {
styles := newTUIStyles()
currentView := viewList
if initialChatID != nil {
currentView = viewChat
}
chat := newChatViewModel(ctx, client, workspaceID, modelOverride, 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,
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.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 {
return tea.WindowSizeMsg{Width: m.width, Height: max(0, m.height-1)}
}
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) 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 == 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 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 ""
}
body := m.list.View()
if m.currentView == viewChat {
body = m.chat.View()
}
base := m.styles.title.Render("Coder Chats") + "\n" + body
switch m.overlay {
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)
}
+804
View File
@@ -0,0 +1,804 @@
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
)
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,
"⏳",
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"))
}
//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.selectedItem.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("💭 "+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, "⏳", 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
}
+770
View File
@@ -0,0 +1,770 @@
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, "⏳")
}},
} {
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{"💭 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")
require.Contains(t, output, "✓ read file")
require.Contains(t, output, "done")
require.Less(t, strings.Index(output, "You: hello"), strings.Index(output, "💭 thinking"))
require.Less(t, strings.Index(output, "💭 thinking"), strings.Index(output, "✓ read file"))
require.Less(t, strings.Index(output, "✓ read file"), strings.LastIndex(output, "done"))
})
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))
}
})
}
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
}
+131
View File
@@ -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())
}
+88
View File
@@ -0,0 +1,88 @@
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
normalItem lipgloss.Style
dimmedText lipgloss.Style
errorText lipgloss.Style
searchInput lipgloss.Style
separator lipgloss.Style
helpText 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),
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),
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)
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -152,6 +152,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.promptExample(),
r.rptyCommand(),
r.syncCommand(),
r.agentsCommand(),
}
}