on main help sign in
Merge branch 'main' into not-needed-keeb
624f583Kujtim Hoxha
CRUSH.md+1−1
@@ -4,7 +4,7 @@
4
4
5- **Build**: `go build .` or `go run .`
5- **Build**: `go build .` or `go run .`
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7−- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
7+- **Lint**: `task lint-fix`
8- **Format**: `task fmt` (gofumpt -w .)
8- **Format**: `task fmt` (gofumpt -w .)
9- **Dev**: `task dev` (runs with profiling enabled)
9- **Dev**: `task dev` (runs with profiling enabled)
10
10
cmd/logs.go+159−53
@@ -1,11 +1,14 @@
1package cmd
1package cmd
2
2
3import (
3import (
4+ "bufio"
4 "encoding/json"
5 "encoding/json"
5 "fmt"
6 "fmt"
7+ "io"
6 "os"
8 "os"
7 "path/filepath"
9 "path/filepath"
8 "slices"
10 "slices"
11+ "strings"
9 "time"
12 "time"
10
13
11 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
14 "github.com/spf13/cobra"
17 "github.com/spf13/cobra"
15)
18)
16
19
17−func init() {
18− rootCmd.AddCommand(logsCmd)
19−}
20−
21var logsCmd = &cobra.Command{
20var logsCmd = &cobra.Command{
22 Use: "logs",
21 Use: "logs",
23 Short: "View crush logs",
22 Short: "View crush logs",
24− Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
23+ Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
25 RunE: func(cmd *cobra.Command, args []string) error {
24 RunE: func(cmd *cobra.Command, args []string) error {
26 cwd, err := cmd.Flags().GetString("cwd")
25 cwd, err := cmd.Flags().GetString("cwd")
27 if err != nil {
26 if err != nil {
28 return fmt.Errorf("failed to get current working directory: %v", err)
27 return fmt.Errorf("failed to get current working directory: %v", err)
29 }
28 }
29+
30+ follow, err := cmd.Flags().GetBool("follow")
31+ if err != nil {
32+ return fmt.Errorf("failed to get follow flag: %v", err)
33+ }
34+
35+ tailLines, err := cmd.Flags().GetInt("tail")
36+ if err != nil {
37+ return fmt.Errorf("failed to get tail flag: %v", err)
38+ }
39+
30 log.SetLevel(log.DebugLevel)
40 log.SetLevel(log.DebugLevel)
41+ // Configure log to output to stdout instead of stderr
42+ log.SetOutput(os.Stdout)
43+
31 cfg, err := config.Load(cwd, false)
44 cfg, err := config.Load(cwd, false)
32 if err != nil {
45 if err != nil {
33 return fmt.Errorf("failed to load configuration: %v", err)
46 return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
38 log.Warn("Looks like you are not in a crush project. No logs found.")
51 log.Warn("Looks like you are not in a crush project. No logs found.")
39 return nil
52 return nil
40 }
53 }
41− t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
42− if err != nil {
43− return fmt.Errorf("failed to tail log file: %v", err)
44− }
45
54
46− // Print the text of each received line
55+ if follow {
47− for line := range t.Lines {
56+ // Follow mode - tail the file continuously
48− var data map[string]any
57+ t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
49− if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
58+ if err != nil {
50− continue
59+ return fmt.Errorf("failed to tail log file: %v", err)
51 }
60 }
52− msg := data["msg"]
53− level := data["level"]
54− otherData := []any{}
55− keys := []string{}
56− for k := range data {
57− keys = append(keys, k)
58− }
59− slices.Sort(keys)
60− for _, k := range keys {
61− switch k {
62− case "msg", "level", "time":
63− continue
64− case "source":
65− source, ok := data[k].(map[string]any)
66− if !ok {
67− continue
68− }
69− sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
70− otherData = append(otherData, "source", sourceFile)
71
61
72− default:
62+ // Print the text of each received line
73− otherData = append(otherData, k, data[k])
63+ for line := range t.Lines {
74− }
64+ printLogLine(line.Text)
75 }
65 }
76− log.SetTimeFunction(func(_ time.Time) time.Time {
66+ } else if tailLines > 0 {
77− // parse the timestamp from the log line if available
67+ // Tail mode - show last N lines
78− t, err := time.Parse(time.RFC3339, data["time"].(string))
68+ lines, err := readLastNLines(logsFile, tailLines)
69+ if err != nil {
70+ return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
71+ }
72+ for _, line := range lines {
73+ printLogLine(line)
74+ }
75+ } else {
76+ // Oneshot mode - read the entire file once
77+ file, err := os.Open(logsFile)
78+ if err != nil {
79+ return fmt.Errorf("failed to open log file: %v", err)
80+ }
81+ defer file.Close()
82+
83+ reader := bufio.NewReader(file)
84+ for {
85+ line, err := reader.ReadString('\n')
79 if err != nil {
86 if err != nil {
80− return time.Now() // fallback to current time if parsing fails
87+ if err == io.EOF && line != "" {
88+ // Handle last line without newline
89+ printLogLine(line)
90+ }
91+ break
81 }
92 }
82− return t
93+ // Remove trailing newline
83− })
94+ line = strings.TrimSuffix(line, "\n")
84− switch level {
95+ printLogLine(line)
85− case "INFO":
86− log.Info(msg, otherData...)
87− case "DEBUG":
88− log.Debug(msg, otherData...)
89− case "ERROR":
90− log.Error(msg, otherData...)
91− case "WARN":
92− log.Warn(msg, otherData...)
93− default:
94− log.Info(msg, otherData...)
95 }
96 }
96 }
97 }
98+
97 return nil
99 return nil
98 },
100 },
99}
101}
102+
103+func init() {
104+ logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
105+ logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
106+ rootCmd.AddCommand(logsCmd)
107+}
108+
109+// readLastNLines reads the last N lines from a file using a simple circular buffer approach
110+func readLastNLines(filename string, n int) ([]string, error) {
111+ file, err := os.Open(filename)
112+ if err != nil {
113+ return nil, err
114+ }
115+ defer file.Close()
116+
117+ // Use a circular buffer to keep only the last N lines
118+ lines := make([]string, n)
119+ count := 0
120+ index := 0
121+
122+ reader := bufio.NewReader(file)
123+ for {
124+ line, err := reader.ReadString('\n')
125+ if err != nil {
126+ if err == io.EOF && line != "" {
127+ // Handle last line without newline
128+ line = strings.TrimSuffix(line, "\n")
129+ lines[index] = line
130+ count++
131+ index = (index + 1) % n
132+ }
133+ break
134+ }
135+ // Remove trailing newline
136+ line = strings.TrimSuffix(line, "\n")
137+ lines[index] = line
138+ count++
139+ index = (index + 1) % n
140+ }
141+
142+ // Extract the last N lines in correct order
143+ if count <= n {
144+ // We have fewer lines than requested, return them all
145+ return lines[:count], nil
146+ }
147+
148+ // We have more lines than requested, extract from circular buffer
149+ result := make([]string, n)
150+ for i := range n {
151+ result[i] = lines[(index+i)%n]
152+ }
153+ return result, nil
154+}
155+
156+func printLogLine(lineText string) {
157+ var data map[string]any
158+ if err := json.Unmarshal([]byte(lineText), &data); err != nil {
159+ return
160+ }
161+ msg := data["msg"]
162+ level := data["level"]
163+ otherData := []any{}
164+ keys := []string{}
165+ for k := range data {
166+ keys = append(keys, k)
167+ }
168+ slices.Sort(keys)
169+ for _, k := range keys {
170+ switch k {
171+ case "msg", "level", "time":
172+ continue
173+ case "source":
174+ source, ok := data[k].(map[string]any)
175+ if !ok {
176+ continue
177+ }
178+ sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
179+ otherData = append(otherData, "source", sourceFile)
180+
181+ default:
182+ otherData = append(otherData, k, data[k])
183+ }
184+ }
185+ log.SetTimeFunction(func(_ time.Time) time.Time {
186+ // parse the timestamp from the log line if available
187+ t, err := time.Parse(time.RFC3339, data["time"].(string))
188+ if err != nil {
189+ return time.Now() // fallback to current time if parsing fails
190+ }
191+ return t
192+ })
193+ switch level {
194+ case "INFO":
195+ log.Info(msg, otherData...)
196+ case "DEBUG":
197+ log.Debug(msg, otherData...)
198+ case "ERROR":
199+ log.Error(msg, otherData...)
200+ case "WARN":
201+ log.Warn(msg, otherData...)
202+ default:
203+ log.Info(msg, otherData...)
204+ }
205+}
cmd/root.go+1−2
@@ -85,7 +85,6 @@ to assist developers in writing, debugging, and understanding code directly from
85 slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
85 slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
86 return err
86 return err
87 }
87 }
88− // Defer shutdown here so it runs for both interactive and non-interactive modes
89 defer app.Shutdown()
88 defer app.Shutdown()
90
89
91 // Initialize MCP tools early for both modes
90 // Initialize MCP tools early for both modes
@@ -107,6 +106,7 @@ to assist developers in writing, debugging, and understanding code directly from
107 program := tea.NewProgram(
106 program := tea.NewProgram(
108 tui.New(app),
107 tui.New(app),
109 tea.WithAltScreen(),
108 tea.WithAltScreen(),
109+ tea.WithContext(ctx),
110 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
110 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
111 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
111 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
112 )
112 )
@@ -117,7 +117,6 @@ to assist developers in writing, debugging, and understanding code directly from
117 slog.Error(fmt.Sprintf("TUI run error: %v", err))
117 slog.Error(fmt.Sprintf("TUI run error: %v", err))
118 return fmt.Errorf("TUI error: %v", err)
118 return fmt.Errorf("TUI error: %v", err)
119 }
119 }
120− app.Shutdown()
121 return nil
120 return nil
122 },
121 },
123}
122}
internal/app/app.go+3−3
@@ -282,6 +282,9 @@ func (app *App) Subscribe(program *tea.Program) {
282
282
283// Shutdown performs a clean shutdown of the application
283// Shutdown performs a clean shutdown of the application
284func (app *App) Shutdown() {
284func (app *App) Shutdown() {
285+ if app.CoderAgent != nil {
286+ app.CoderAgent.CancelAll()
287+ }
285 app.cancelFuncsMutex.Lock()
288 app.cancelFuncsMutex.Lock()
286 for _, cancel := range app.watcherCancelFuncs {
289 for _, cancel := range app.watcherCancelFuncs {
287 cancel()
290 cancel()
@@ -301,9 +304,6 @@ func (app *App) Shutdown() {
301 }
304 }
302 cancel()
305 cancel()
303 }
306 }
304− if app.CoderAgent != nil {
305− app.CoderAgent.CancelAll()
306− }
307
307
308 for _, cleanup := range app.cleanupFuncs {
308 for _, cleanup := range app.cleanupFuncs {
309 if cleanup != nil {
309 if cleanup != nil {
internal/config/load.go+1
@@ -301,6 +301,7 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
301 defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
301 defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
302 if defaultSmallModel == nil {
302 if defaultSmallModel == nil {
303 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
303 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
304+ return
304 }
305 }
305 smallModel = SelectedModel{
306 smallModel = SelectedModel{
306 Provider: string(p.ID),
307 Provider: string(p.ID),
internal/llm/agent/agent.go+22−13
@@ -403,7 +403,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
403 agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
403 agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
404 if err != nil {
404 if err != nil {
405 if errors.Is(err, context.Canceled) {
405 if errors.Is(err, context.Canceled) {
406− agentMessage.AddFinish(message.FinishReasonCanceled)
406+ agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
407 a.messages.Update(context.Background(), agentMessage)
407 a.messages.Update(context.Background(), agentMessage)
408 return a.err(ErrRequestCancelled)
408 return a.err(ErrRequestCancelled)
409 }
409 }
@@ -454,11 +454,15 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
454 // Process each event in the stream.
454 // Process each event in the stream.
455 for event := range eventChan {
455 for event := range eventChan {
456 if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
456 if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
457+ if errors.Is(processErr, context.Canceled) {
458+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
459+ } else {
457− a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
460+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
461+ }
458 return assistantMsg, nil, processErr
462 return assistantMsg, nil, processErr
459 }
463 }
460 if ctx.Err() != nil {
464 if ctx.Err() != nil {
461− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
465+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
462 return assistantMsg, nil, ctx.Err()
466 return assistantMsg, nil, ctx.Err()
463 }
467 }
464 }
468 }
@@ -468,7 +472,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
468 for i, toolCall := range toolCalls {
472 for i, toolCall := range toolCalls {
469 select {
473 select {
470 case <-ctx.Done():
474 case <-ctx.Done():
471− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
475+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
472 // Make all future tool calls cancelled
476 // Make all future tool calls cancelled
473 for j := i; j < len(toolCalls); j++ {
477 for j := i; j < len(toolCalls); j++ {
474 toolResults[j] = message.ToolResult{
478 toolResults[j] = message.ToolResult{
@@ -516,7 +520,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
516 IsError: true,
520 IsError: true,
517 }
521 }
518 }
522 }
519− a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
523+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
520 break
524 break
521 }
525 }
522 }
526 }
@@ -548,8 +552,8 @@ out:
548 return assistantMsg, &msg, err
552 return assistantMsg, &msg, err
549}
553}
550
554
551−func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
555+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason, message, details string) {
552− msg.AddFinish(finishReson)
556+ msg.AddFinish(finishReson, message, details)
553 _ = a.messages.Update(ctx, *msg)
557 _ = a.messages.Update(ctx, *msg)
554}
558}
555
559
@@ -580,15 +584,10 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
580 assistantMsg.FinishToolCall(event.ToolCall.ID)
584 assistantMsg.FinishToolCall(event.ToolCall.ID)
581 return a.messages.Update(ctx, *assistantMsg)
585 return a.messages.Update(ctx, *assistantMsg)
582 case provider.EventError:
586 case provider.EventError:
583− if errors.Is(event.Error, context.Canceled) {
584− slog.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
585− return context.Canceled
586− }
587− slog.Error(event.Error.Error())
588 return event.Error
587 return event.Error
589 case provider.EventComplete:
588 case provider.EventComplete:
590 assistantMsg.SetToolCalls(event.Response.ToolCalls)
589 assistantMsg.SetToolCalls(event.Response.ToolCalls)
591− assistantMsg.AddFinish(event.Response.FinishReason)
590+ assistantMsg.AddFinish(event.Response.FinishReason, "", "")
592 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
591 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
593 return fmt.Errorf("failed to update message: %w", err)
592 return fmt.Errorf("failed to update message: %w", err)
594 }
593 }
@@ -801,6 +800,16 @@ func (a *agent) CancelAll() {
801 a.Cancel(key.(string)) // key is sessionID
800 a.Cancel(key.(string)) // key is sessionID
802 return true
801 return true
803 })
802 })
803+
804+ timeout := time.After(5 * time.Second)
805+ for a.IsBusy() {
806+ select {
807+ case <-timeout:
808+ return
809+ default:
810+ time.Sleep(200 * time.Millisecond)
811+ }
812+ }
804}
813}
805
814
806func (a *agent) UpdateModel() error {
815func (a *agent) UpdateModel() error {
internal/message/content.go+6−4
@@ -102,8 +102,10 @@ type ToolResult struct {
102func (ToolResult) isPart() {}
102func (ToolResult) isPart() {}
103
103
104type Finish struct {
104type Finish struct {
105− Reason FinishReason `json:"reason"`
105+ Reason FinishReason `json:"reason"`
106− Time int64 `json:"time"`
106+ Time int64 `json:"time"`
107+ Message string `json:"message,omitempty"`
108+ Details string `json:"details,omitempty"`
107}
109}
108
110
109func (Finish) isPart() {}
111func (Finish) isPart() {}
@@ -308,7 +310,7 @@ func (m *Message) SetToolResults(tr []ToolResult) {
308 }
310 }
309}
311}
310
312
311−func (m *Message) AddFinish(reason FinishReason) {
313+func (m *Message) AddFinish(reason FinishReason, message, details string) {
312 // remove any existing finish part
314 // remove any existing finish part
313 for i, part := range m.Parts {
315 for i, part := range m.Parts {
314 if _, ok := part.(Finish); ok {
316 if _, ok := part.(Finish); ok {
@@ -316,7 +318,7 @@ func (m *Message) AddFinish(reason FinishReason) {
316 break
318 break
317 }
319 }
318 }
320 }
319− m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
321+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
320}
322}
321
323
322func (m *Message) AddImageURL(url, detail string) {
324func (m *Message) AddImageURL(url, detail string) {
internal/tui/components/chat/chat.go+6−3
@@ -337,7 +337,7 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
337 var cmds []tea.Cmd
337 var cmds []tea.Cmd
338
338
339 for _, tc := range msg.ToolCalls() {
339 for _, tc := range msg.ToolCalls() {
340− if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
340+ if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
341 cmds = append(cmds, cmd)
341 cmds = append(cmds, cmd)
342 }
342 }
343 }
343 }
@@ -346,18 +346,21 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
346}
346}
347
347
348// updateOrAddToolCall updates an existing tool call or adds a new one.
348// updateOrAddToolCall updates an existing tool call or adds a new one.
349−func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
349+func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
350 // Try to find existing tool call
350 // Try to find existing tool call
351 for index, existingTC := range existingToolCalls {
351 for index, existingTC := range existingToolCalls {
352 if tc.ID == existingTC.GetToolCall().ID {
352 if tc.ID == existingTC.GetToolCall().ID {
353 existingTC.SetToolCall(tc)
353 existingTC.SetToolCall(tc)
354+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
355+ existingTC.SetCancelled()
356+ }
354 m.listCmp.UpdateItem(index, existingTC)
357 m.listCmp.UpdateItem(index, existingTC)
355 return nil
358 return nil
356 }
359 }
357 }
360 }
358
361
359 // Add new tool call if not found
362 // Add new tool call if not found
360− return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
363+ return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
361}
364}
362
365
363// handleNewAssistantMessage processes new assistant messages and their tool calls.
366// handleNewAssistantMessage processes new assistant messages and their tool calls.
internal/tui/components/chat/editor/editor.go+3
@@ -130,6 +130,9 @@ func (m *editorCmp) Init() tea.Cmd {
130}
130}
131
131
132func (m *editorCmp) send() tea.Cmd {
132func (m *editorCmp) send() tea.Cmd {
133+ if m.app.CoderAgent == nil {
134+ return util.ReportError(fmt.Errorf("coder agent is not initialized"))
135+ }
133 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
136 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
134 return util.ReportWarn("Agent is working, please wait...")
137 return util.ReportWarn("Agent is working, please wait...")
135 }
138 }
internal/tui/components/chat/header/header.go+6
@@ -21,6 +21,7 @@ type Header interface {
21 SetSession(session session.Session) tea.Cmd
21 SetSession(session session.Session) tea.Cmd
22 SetWidth(width int) tea.Cmd
22 SetWidth(width int) tea.Cmd
23 SetDetailsOpen(open bool)
23 SetDetailsOpen(open bool)
24+ ShowingDetails() bool
24}
25}
25
26
26type header struct {
27type header struct {
@@ -137,3 +138,8 @@ func (h *header) SetWidth(width int) tea.Cmd {
137 h.width = width
138 h.width = width
138 return nil
139 return nil
139}
140}
141+
142+// ShowingDetails implements Header.
143+func (h *header) ShowingDetails() bool {
144+ return h.detailsOpen
145+}
internal/tui/components/chat/messages/messages.go+8
@@ -8,6 +8,7 @@ import (
8
8
9 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11+ "github.com/charmbracelet/x/ansi"
11
12
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -184,6 +185,7 @@ func (m *messageCmp) toMarkdown(content string) string {
184// markdownContent processes the message content and handles special states.
185// markdownContent processes the message content and handles special states.
185// Returns appropriate content for thinking, finished, and error states.
186// Returns appropriate content for thinking, finished, and error states.
186func (m *messageCmp) markdownContent() string {
187func (m *messageCmp) markdownContent() string {
188+ t := styles.CurrentTheme()
187 content := m.message.Content().String()
189 content := m.message.Content().String()
188 if m.message.Role == message.Assistant {
190 if m.message.Role == message.Assistant {
189 thinking := m.message.IsThinking()
191 thinking := m.message.IsThinking()
@@ -199,6 +201,12 @@ func (m *messageCmp) markdownContent() string {
199 content = ""
201 content = ""
200 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
202 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
201 content = "*Canceled*"
203 content = "*Canceled*"
204+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
205+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
206+ truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
207+ title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
208+ details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
209+ return fmt.Sprintf("%s\n\n%s", title, details)
202 }
210 }
203 }
211 }
204 return m.toMarkdown(content)
212 return m.toMarkdown(content)
internal/tui/components/chat/sidebar/sidebar.go+427−11
@@ -33,6 +33,16 @@ type FileHistory struct {
33 latestVersion history.File
33 latestVersion history.File
34}
34}
35
35
36+const LogoHeightBreakpoint = 40
37+
38+// Default maximum number of items to show in each section
39+const (
40+ DefaultMaxFilesShown = 10
41+ DefaultMaxLSPsShown = 8
42+ DefaultMaxMCPsShown = 8
43+ MinItemsPerSection = 2 // Minimum items to show per section
44+)
45+
36type SessionFile struct {
46type SessionFile struct {
37 History FileHistory
47 History FileHistory
38 FilePath string
48 FilePath string
@@ -100,8 +110,14 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100func (m *sidebarCmp) View() string {
110func (m *sidebarCmp) View() string {
101 t := styles.CurrentTheme()
111 t := styles.CurrentTheme()
102 parts := []string{}
112 parts := []string{}
113+
103 if !m.compactMode {
114 if !m.compactMode {
115+ if m.height > LogoHeightBreakpoint {
104− parts = append(parts, m.logo)
116+ parts = append(parts, m.logo)
117+ } else {
118+ // Use a smaller logo for smaller screens
119+ parts = append(parts, m.smallerScreenLogo(), "")
120+ }
105 }
121 }
106
122
107 if !m.compactMode && m.session.ID != "" {
123 if !m.compactMode && m.session.ID != "" {
@@ -119,15 +135,26 @@ func (m *sidebarCmp) View() string {
119 parts = append(parts,
135 parts = append(parts,
120 m.currentModelBlock(),
136 m.currentModelBlock(),
121 )
137 )
138+
139+ // Check if we should use horizontal layout for sections
140+ if m.compactMode && m.width > m.height {
141+ // Horizontal layout for compact mode when width > height
142+ sectionsContent := m.renderSectionsHorizontal()
143+ if sectionsContent != "" {
144+ parts = append(parts, "", sectionsContent)
145+ }
146+ } else {
147+ // Vertical layout (default)
122− if m.session.ID != "" {
148+ if m.session.ID != "" {
123− parts = append(parts, "", m.filesBlock())
149+ parts = append(parts, "", m.filesBlock())
150+ }
151+ parts = append(parts,
152+ "",
153+ m.lspBlock(),
154+ "",
155+ m.mcpBlock(),
156+ )
124 }
157 }
125− parts = append(parts,
126− "",
127− m.lspBlock(),
128− "",
129− m.mcpBlock(),
130− )
131
158
132 style := t.S().Base.
159 style := t.S().Base.
133 Width(m.width).
160 Width(m.width).
@@ -258,6 +285,328 @@ func (m *sidebarCmp) getMaxWidth() int {
258 return min(m.width-2, 58) // -2 for padding
285 return min(m.width-2, 58) // -2 for padding
259}
286}
260
287
288+// calculateAvailableHeight estimates how much height is available for dynamic content
289+func (m *sidebarCmp) calculateAvailableHeight() int {
290+ usedHeight := 0
291+
292+ if !m.compactMode {
293+ if m.height > LogoHeightBreakpoint {
294+ usedHeight += 7 // Approximate logo height
295+ } else {
296+ usedHeight += 2 // Smaller logo height
297+ }
298+ usedHeight += 1 // Empty line after logo
299+ }
300+
301+ if m.session.ID != "" {
302+ usedHeight += 1 // Title line
303+ usedHeight += 1 // Empty line after title
304+ }
305+
306+ if !m.compactMode {
307+ usedHeight += 1 // CWD line
308+ usedHeight += 1 // Empty line after CWD
309+ }
310+
311+ usedHeight += 2 // Model info
312+
313+ usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
314+
315+ // Base padding
316+ usedHeight += 2 // Top and bottom padding
317+
318+ return max(0, m.height-usedHeight)
319+}
320+
321+// getDynamicLimits calculates how many items to show in each section based on available height
322+func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
323+ availableHeight := m.calculateAvailableHeight()
324+
325+ // If we have very little space, use minimum values
326+ if availableHeight < 10 {
327+ return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
328+ }
329+
330+ // Distribute available height among the three sections
331+ // Give priority to files, then LSPs, then MCPs
332+ totalSections := 3
333+ heightPerSection := availableHeight / totalSections
334+
335+ // Calculate limits for each section, ensuring minimums
336+ maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
337+ maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
338+ maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
339+
340+ // If we have extra space, give it to files first
341+ remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
342+ if remainingHeight > 0 {
343+ extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
344+ maxFiles += extraForFiles
345+ remainingHeight -= extraForFiles
346+
347+ if remainingHeight > 0 {
348+ extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
349+ maxLSPs += extraForLSPs
350+ remainingHeight -= extraForLSPs
351+
352+ if remainingHeight > 0 {
353+ maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
354+ }
355+ }
356+ }
357+
358+ return maxFiles, maxLSPs, maxMCPs
359+}
360+
361+// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
362+func (m *sidebarCmp) renderSectionsHorizontal() string {
363+ // Calculate available width for each section
364+ totalWidth := m.width - 4 // Account for padding and spacing
365+ sectionWidth := min(50, totalWidth/3)
366+
367+ // Get the sections content with limited height
368+ var filesContent, lspContent, mcpContent string
369+
370+ filesContent = m.filesBlockCompact(sectionWidth)
371+ lspContent = m.lspBlockCompact(sectionWidth)
372+ mcpContent = m.mcpBlockCompact(sectionWidth)
373+
374+ return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
375+}
376+
377+// filesBlockCompact renders the files block with limited width and height for horizontal layout
378+func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
379+ t := styles.CurrentTheme()
380+
381+ section := t.S().Subtle.Render("Modified Files")
382+
383+ files := make([]SessionFile, 0)
384+ m.files.Range(func(key, value any) bool {
385+ file := value.(SessionFile)
386+ files = append(files, file)
387+ return true
388+ })
389+
390+ if len(files) == 0 {
391+ content := lipgloss.JoinVertical(
392+ lipgloss.Left,
393+ section,
394+ "",
395+ t.S().Base.Foreground(t.Border).Render("None"),
396+ )
397+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
398+ }
399+
400+ fileList := []string{section, ""}
401+ sort.Slice(files, func(i, j int) bool {
402+ return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
403+ })
404+
405+ // Limit items for horizontal layout - use less space
406+ maxItems := min(5, len(files))
407+ availableHeight := m.height - 8 // Reserve space for header and other content
408+ if availableHeight > 0 {
409+ maxItems = min(maxItems, availableHeight)
410+ }
411+
412+ filesShown := 0
413+ for _, file := range files {
414+ if file.Additions == 0 && file.Deletions == 0 {
415+ continue
416+ }
417+ if filesShown >= maxItems {
418+ break
419+ }
420+
421+ var statusParts []string
422+ if file.Additions > 0 {
423+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
424+ }
425+ if file.Deletions > 0 {
426+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
427+ }
428+
429+ extraContent := strings.Join(statusParts, " ")
430+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
431+ filePath := file.FilePath
432+ filePath = strings.TrimPrefix(filePath, cwd)
433+ filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
434+ filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
435+
436+ fileList = append(fileList,
437+ core.Status(
438+ core.StatusOpts{
439+ IconColor: t.FgMuted,
440+ NoIcon: true,
441+ Title: filePath,
442+ ExtraContent: extraContent,
443+ },
444+ maxWidth,
445+ ),
446+ )
447+ filesShown++
448+ }
449+
450+ // Add "..." indicator if there are more files
451+ totalFilesWithChanges := 0
452+ for _, file := range files {
453+ if file.Additions > 0 || file.Deletions > 0 {
454+ totalFilesWithChanges++
455+ }
456+ }
457+ if totalFilesWithChanges > maxItems {
458+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
459+ }
460+
461+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
462+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
463+}
464+
465+// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
466+func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
467+ t := styles.CurrentTheme()
468+
469+ section := t.S().Subtle.Render("LSPs")
470+
471+ lspList := []string{section, ""}
472+
473+ lsp := config.Get().LSP.Sorted()
474+ if len(lsp) == 0 {
475+ content := lipgloss.JoinVertical(
476+ lipgloss.Left,
477+ section,
478+ "",
479+ t.S().Base.Foreground(t.Border).Render("None"),
480+ )
481+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
482+ }
483+
484+ // Limit items for horizontal layout
485+ maxItems := min(5, len(lsp))
486+ availableHeight := m.height - 8
487+ if availableHeight > 0 {
488+ maxItems = min(maxItems, availableHeight)
489+ }
490+
491+ for i, l := range lsp {
492+ if i >= maxItems {
493+ break
494+ }
495+
496+ iconColor := t.Success
497+ if l.LSP.Disabled {
498+ iconColor = t.FgMuted
499+ }
500+
501+ lspErrs := map[protocol.DiagnosticSeverity]int{
502+ protocol.SeverityError: 0,
503+ protocol.SeverityWarning: 0,
504+ protocol.SeverityHint: 0,
505+ protocol.SeverityInformation: 0,
506+ }
507+ if client, ok := m.lspClients[l.Name]; ok {
508+ for _, diagnostics := range client.GetDiagnostics() {
509+ for _, diagnostic := range diagnostics {
510+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
511+ lspErrs[diagnostic.Severity] = severity + 1
512+ }
513+ }
514+ }
515+ }
516+
517+ errs := []string{}
518+ if lspErrs[protocol.SeverityError] > 0 {
519+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
520+ }
521+ if lspErrs[protocol.SeverityWarning] > 0 {
522+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
523+ }
524+ if lspErrs[protocol.SeverityHint] > 0 {
525+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
526+ }
527+ if lspErrs[protocol.SeverityInformation] > 0 {
528+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
529+ }
530+
531+ lspList = append(lspList,
532+ core.Status(
533+ core.StatusOpts{
534+ IconColor: iconColor,
535+ Title: l.Name,
536+ Description: l.LSP.Command,
537+ ExtraContent: strings.Join(errs, " "),
538+ },
539+ maxWidth,
540+ ),
541+ )
542+ }
543+
544+ // Add "..." indicator if there are more LSPs
545+ if len(lsp) > maxItems {
546+ lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
547+ }
548+
549+ content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
550+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
551+}
552+
553+// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
554+func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
555+ t := styles.CurrentTheme()
556+
557+ section := t.S().Subtle.Render("MCPs")
558+
559+ mcpList := []string{section, ""}
560+
561+ mcps := config.Get().MCP.Sorted()
562+ if len(mcps) == 0 {
563+ content := lipgloss.JoinVertical(
564+ lipgloss.Left,
565+ section,
566+ "",
567+ t.S().Base.Foreground(t.Border).Render("None"),
568+ )
569+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
570+ }
571+
572+ // Limit items for horizontal layout
573+ maxItems := min(5, len(mcps))
574+ availableHeight := m.height - 8
575+ if availableHeight > 0 {
576+ maxItems = min(maxItems, availableHeight)
577+ }
578+
579+ for i, l := range mcps {
580+ if i >= maxItems {
581+ break
582+ }
583+
584+ iconColor := t.Success
585+ if l.MCP.Disabled {
586+ iconColor = t.FgMuted
587+ }
588+
589+ mcpList = append(mcpList,
590+ core.Status(
591+ core.StatusOpts{
592+ IconColor: iconColor,
593+ Title: l.Name,
594+ Description: l.MCP.Command,
595+ },
596+ maxWidth,
597+ ),
598+ )
599+ }
600+
601+ // Add "..." indicator if there are more MCPs
602+ if len(mcps) > maxItems {
603+ mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
604+ }
605+
606+ content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
607+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
608+}
609+
261func (m *sidebarCmp) filesBlock() string {
610func (m *sidebarCmp) filesBlock() string {
262 t := styles.CurrentTheme()
611 t := styles.CurrentTheme()
263
612
@@ -286,10 +635,19 @@ func (m *sidebarCmp) filesBlock() string {
286 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
635 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
287 })
636 })
288
637
638+ // Limit the number of files shown
639+ maxFiles, _, _ := m.getDynamicLimits()
640+ maxFiles = min(len(files), maxFiles)
641+ filesShown := 0
642+
289 for _, file := range files {
643 for _, file := range files {
290 if file.Additions == 0 && file.Deletions == 0 {
644 if file.Additions == 0 && file.Deletions == 0 {
291 continue // skip files with no changes
645 continue // skip files with no changes
292 }
646 }
647+ if filesShown >= maxFiles {
648+ break
649+ }
650+
293 var statusParts []string
651 var statusParts []string
294 if file.Additions > 0 {
652 if file.Additions > 0 {
295 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
653 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -315,6 +673,21 @@ func (m *sidebarCmp) filesBlock() string {
315 m.getMaxWidth(),
673 m.getMaxWidth(),
316 ),
674 ),
317 )
675 )
676+ filesShown++
677+ }
678+
679+ // Add indicator if there are more files
680+ totalFilesWithChanges := 0
681+ for _, file := range files {
682+ if file.Additions > 0 || file.Deletions > 0 {
683+ totalFilesWithChanges++
684+ }
685+ }
686+ if totalFilesWithChanges > maxFiles {
687+ remaining := totalFilesWithChanges - maxFiles
688+ fileList = append(fileList,
689+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
690+ )
318 }
691 }
319
692
320 return lipgloss.JoinVertical(
693 return lipgloss.JoinVertical(
@@ -342,7 +715,14 @@ func (m *sidebarCmp) lspBlock() string {
342 )
715 )
343 }
716 }
344
717
718+ // Limit the number of LSPs shown
719+ _, maxLSPs, _ := m.getDynamicLimits()
720+ maxLSPs = min(len(lsp), maxLSPs)
345− for _, l := range lsp {
721+ for i, l := range lsp {
722+ if i >= maxLSPs {
723+ break
724+ }
725+
346 iconColor := t.Success
726 iconColor := t.Success
347 if l.LSP.Disabled {
727 if l.LSP.Disabled {
348 iconColor = t.FgMuted
728 iconColor = t.FgMuted
@@ -390,6 +770,14 @@ func (m *sidebarCmp) lspBlock() string {
390 )
770 )
391 }
771 }
392
772
773+ // Add indicator if there are more LSPs
774+ if len(lsp) > maxLSPs {
775+ remaining := len(lsp) - maxLSPs
776+ lspList = append(lspList,
777+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
778+ )
779+ }
780+
393 return lipgloss.JoinVertical(
781 return lipgloss.JoinVertical(
394 lipgloss.Left,
782 lipgloss.Left,
395 lspList...,
783 lspList...,
@@ -415,7 +803,14 @@ func (m *sidebarCmp) mcpBlock() string {
415 )
803 )
416 }
804 }
417
805
806+ // Limit the number of MCPs shown
807+ _, _, maxMCPs := m.getDynamicLimits()
808+ maxMCPs = min(len(mcps), maxMCPs)
418− for _, l := range mcps {
809+ for i, l := range mcps {
810+ if i >= maxMCPs {
811+ break
812+ }
813+
419 iconColor := t.Success
814 iconColor := t.Success
420 if l.MCP.Disabled {
815 if l.MCP.Disabled {
421 iconColor = t.FgMuted
816 iconColor = t.FgMuted
@@ -432,6 +827,14 @@ func (m *sidebarCmp) mcpBlock() string {
432 )
827 )
433 }
828 }
434
829
830+ // Add indicator if there are more MCPs
831+ if len(mcps) > maxMCPs {
832+ remaining := len(mcps) - maxMCPs
833+ mcpList = append(mcpList,
834+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
835+ )
836+ }
837+
435 return lipgloss.JoinVertical(
838 return lipgloss.JoinVertical(
436 lipgloss.Left,
839 lipgloss.Left,
437 mcpList...,
840 mcpList...,
@@ -504,6 +907,19 @@ func (s *sidebarCmp) currentModelBlock() string {
504 )
907 )
505}
908}
506
909
910+func (m *sidebarCmp) smallerScreenLogo() string {
911+ t := styles.CurrentTheme()
912+ title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
913+ title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
914+ remainingWidth := m.width - lipgloss.Width(title) - 3
915+ if remainingWidth > 0 {
916+ char := "╱"
917+ lines := strings.Repeat(char, remainingWidth)
918+ title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
919+ }
920+ return title
921+}
922+
507// SetSession implements Sidebar.
923// SetSession implements Sidebar.
508func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
924func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
509 m.session = session
925 m.session = session
internal/tui/components/chat/splash/splash.go+2−4
@@ -192,10 +192,8 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192 return s, s.initializeProject()
192 return s, s.initializeProject()
193 }
193 }
194 case key.Matches(msg, s.keyMap.No):
194 case key.Matches(msg, s.keyMap.No):
195− if s.needsProjectInit {
195+ s.selectedNo = true
196− s.needsProjectInit = false
196+ return s, s.initializeProject()
197− return s, util.CmdHandler(OnboardingCompleteMsg{})
198− }
199 default:
197 default:
200 if s.needsAPIKey {
198 if s.needsAPIKey {
201 u, cmd := s.apiKeyInput.Update(msg)
199 u, cmd := s.apiKeyInput.Update(msg)
internal/tui/components/core/status_test.gonew file+147
@@ -0,0 +1,147 @@
1+package core_test
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/tui/components/core"
9+ "github.com/charmbracelet/x/exp/golden"
10+)
11+
12+func TestStatus(t *testing.T) {
13+ t.Parallel()
14+
15+ tests := []struct {
16+ name string
17+ opts core.StatusOpts
18+ width int
19+ }{
20+ {
21+ name: "Default",
22+ opts: core.StatusOpts{
23+ Title: "Status",
24+ Description: "Everything is working fine",
25+ },
26+ width: 80,
27+ },
28+ {
29+ name: "WithCustomIcon",
30+ opts: core.StatusOpts{
31+ Icon: "✓",
32+ Title: "Success",
33+ Description: "Operation completed successfully",
34+ },
35+ width: 80,
36+ },
37+ {
38+ name: "NoIcon",
39+ opts: core.StatusOpts{
40+ NoIcon: true,
41+ Title: "Info",
42+ Description: "This status has no icon",
43+ },
44+ width: 80,
45+ },
46+ {
47+ name: "WithColors",
48+ opts: core.StatusOpts{
49+ Icon: "⚠",
50+ IconColor: color.RGBA{255, 165, 0, 255}, // Orange
51+ Title: "Warning",
52+ TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
53+ Description: "This is a warning message",
54+ DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
55+ },
56+ width: 80,
57+ },
58+ {
59+ name: "WithExtraContent",
60+ opts: core.StatusOpts{
61+ Title: "Build",
62+ Description: "Building project",
63+ ExtraContent: "[2/5]",
64+ },
65+ width: 80,
66+ },
67+ {
68+ name: "LongDescription",
69+ opts: core.StatusOpts{
70+ Title: "Processing",
71+ Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
72+ },
73+ width: 60,
74+ },
75+ {
76+ name: "NarrowWidth",
77+ opts: core.StatusOpts{
78+ Icon: "●",
79+ Title: "Status",
80+ Description: "Short message",
81+ },
82+ width: 30,
83+ },
84+ {
85+ name: "VeryNarrowWidth",
86+ opts: core.StatusOpts{
87+ Icon: "●",
88+ Title: "Test",
89+ Description: "This will be truncated",
90+ },
91+ width: 20,
92+ },
93+ {
94+ name: "EmptyDescription",
95+ opts: core.StatusOpts{
96+ Icon: "●",
97+ Title: "Title Only",
98+ },
99+ width: 80,
100+ },
101+ {
102+ name: "AllFieldsWithExtraContent",
103+ opts: core.StatusOpts{
104+ Icon: "🚀",
105+ IconColor: color.RGBA{0, 255, 0, 255}, // Green
106+ Title: "Deployment",
107+ TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108+ Description: "Deploying to production environment",
109+ DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
110+ ExtraContent: "v1.2.3",
111+ },
112+ width: 80,
113+ },
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ t.Parallel()
119+
120+ output := core.Status(tt.opts, tt.width)
121+ golden.RequireEqual(t, []byte(output))
122+ })
123+ }
124+}
125+
126+func TestStatusTruncation(t *testing.T) {
127+ t.Parallel()
128+
129+ opts := core.StatusOpts{
130+ Icon: "●",
131+ Title: "Very Long Title",
132+ Description: "This is an extremely long description that definitely needs to be truncated",
133+ ExtraContent: "[extra]",
134+ }
135+
136+ // Test different widths to ensure truncation works correctly
137+ widths := []int{20, 30, 40, 50, 60}
138+
139+ for _, width := range widths {
140+ t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
141+ t.Parallel()
142+
143+ output := core.Status(opts, width)
144+ golden.RequireEqual(t, []byte(output))
145+ })
146+ }
147+}
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.goldennew file+1
@@ -0,0 +1 @@
1+🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.goldennew file+1
@@ -0,0 +1 @@
1+● Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.goldennew file+1
@@ -0,0 +1 @@
1+● Title Only 
internal/tui/components/core/testdata/TestStatus/LongDescription.goldennew file+1
@@ -0,0 +1 @@
1+● Processing This is a very long description that should be…
internal/tui/components/core/testdata/TestStatus/NarrowWidth.goldennew file+1
@@ -0,0 +1 @@
1+● Status Short message
internal/tui/components/core/testdata/TestStatus/NoIcon.goldennew file+1
@@ -0,0 +1 @@
1+Info This status has no icon
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.goldennew file+1
@@ -0,0 +1 @@
1+● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.goldennew file+1
@@ -0,0 +1 @@
1+⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.goldennew file+1
@@ -0,0 +1 @@
1+✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.goldennew file+1
@@ -0,0 +1 @@
1+● Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/dialogs/permissions/permissions.go+13−6
@@ -60,6 +60,9 @@ type permissionDialogCmp struct {
60 cachedContent string
60 cachedContent string
61 contentDirty bool
61 contentDirty bool
62
62
63+ positionRow int // Row position for dialog
64+ positionCol int // Column position for dialog
65+
63 keyMap KeyMap
66 keyMap KeyMap
64}
67}
65
68
@@ -446,6 +449,10 @@ func (p *permissionDialogCmp) render() string {
446 p.contentViewPort.SetHeight(contentHeight)
449 p.contentViewPort.SetHeight(contentHeight)
447 p.contentViewPort.SetContent(contentFinal)
450 p.contentViewPort.SetContent(contentFinal)
448
451
452+ p.positionRow = p.wHeight / 2
453+ p.positionRow -= (contentHeight + 9) / 2
454+ p.positionRow -= 3 // Move dialog slightly higher than middle
455+
449 var contentHelp string
456 var contentHelp string
450 if p.supportsDiffView() {
457 if p.supportsDiffView() {
451 contentHelp = help.New().View(p.keyMap)
458 contentHelp = help.New().View(p.keyMap)
@@ -509,7 +516,11 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
509 if oldWidth != p.width || oldHeight != p.height {
516 if oldWidth != p.width || oldHeight != p.height {
510 p.contentDirty = true
517 p.contentDirty = true
511 }
518 }
512−
519+ p.positionRow = p.wHeight / 2
520+ p.positionRow -= p.height / 2
521+ p.positionRow -= 3 // Move dialog slightly higher than middle
522+ p.positionCol = p.wWidth / 2
523+ p.positionCol -= p.width / 2
513 return nil
524 return nil
514}
525}
515
526
@@ -529,9 +540,5 @@ func (p *permissionDialogCmp) ID() dialogs.DialogID {
529
540
530// Position implements PermissionDialogCmp.
541// Position implements PermissionDialogCmp.
531func (p *permissionDialogCmp) Position() (int, int) {
542func (p *permissionDialogCmp) Position() (int, int) {
532− row := (p.wHeight / 2) - 2 // Just a bit above the center
543+ return p.positionRow, p.positionCol
533− row -= p.height / 2
534− col := p.wWidth / 2
535− col -= p.width / 2
536− return row, col
537}
544}
internal/tui/page/chat/chat.go+14−10
@@ -51,11 +51,12 @@ const (
51)
51)
52
52
53const (
53const (
54− CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
54+ CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
55+ CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
55− EditorHeight = 5 // Height of the editor input area including padding
56+ EditorHeight = 5 // Height of the editor input area including padding
56− SideBarWidth = 31 // Width of the sidebar
57+ SideBarWidth = 31 // Width of the sidebar
57− SideBarDetailsPadding = 1 // Padding for the sidebar details section
58+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
58− HeaderHeight = 1 // Height of the header
59+ HeaderHeight = 1 // Height of the header
59
60
60 // Layout constants for borders and padding
61 // Layout constants for borders and padding
61 BorderWidth = 1 // Width of component borders
62 BorderWidth = 1 // Width of component borders
@@ -177,7 +178,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177 if p.forceCompact {
178 if p.forceCompact {
178 p.setCompactMode(true)
179 p.setCompactMode(true)
179 cmd = p.updateCompactConfig(true)
180 cmd = p.updateCompactConfig(true)
180− } else if p.width >= CompactModeBreakpoint {
181+ } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
181 p.setCompactMode(false)
182 p.setCompactMode(false)
182 cmd = p.updateCompactConfig(false)
183 cmd = p.updateCompactConfig(false)
183 }
184 }
@@ -313,6 +314,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
313}
314}
314
315
315func (p *chatPage) Cursor() *tea.Cursor {
316func (p *chatPage) Cursor() *tea.Cursor {
317+ if p.header.ShowingDetails() {
318+ return nil
319+ }
316 switch p.focusedPane {
320 switch p.focusedPane {
317 case PanelTypeEditor:
321 case PanelTypeEditor:
318 return p.editor.Cursor()
322 return p.editor.Cursor()
@@ -420,20 +424,20 @@ func (p *chatPage) setCompactMode(compact bool) {
420 }
424 }
421}
425}
422
426
423−func (p *chatPage) handleCompactMode(newWidth int) {
427+func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
424 if p.forceCompact {
428 if p.forceCompact {
425 return
429 return
426 }
430 }
427− if newWidth < CompactModeBreakpoint && !p.compact {
431+ if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
428 p.setCompactMode(true)
432 p.setCompactMode(true)
429 }
433 }
430− if newWidth >= CompactModeBreakpoint && p.compact {
434+ if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
431 p.setCompactMode(false)
435 p.setCompactMode(false)
432 }
436 }
433}
437}
434
438
435func (p *chatPage) SetSize(width, height int) tea.Cmd {
439func (p *chatPage) SetSize(width, height int) tea.Cmd {
436− p.handleCompactMode(width)
440+ p.handleCompactMode(width, height)
437 p.width = width
441 p.width = width
438 p.height = height
442 p.height = height
439 var cmds []tea.Cmd
443 var cmds []tea.Cmd
internal/tui/tui.go+4−4
@@ -315,7 +315,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
315 // dialogs
315 // dialogs
316 case key.Matches(msg, a.keyMap.Quit):
316 case key.Matches(msg, a.keyMap.Quit):
317 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
317 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
318− // if the quit dialog is already open, close the app
319 return tea.Quit
318 return tea.Quit
320 }
319 }
321 return util.CmdHandler(dialogs.OpenDialogMsg{
320 return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -324,20 +323,21 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
324
323
325 case key.Matches(msg, a.keyMap.Commands):
324 case key.Matches(msg, a.keyMap.Commands):
326 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
325 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
327− // If the commands dialog is already open, close it
328 return util.CmdHandler(dialogs.CloseDialogMsg{})
326 return util.CmdHandler(dialogs.CloseDialogMsg{})
329 }
327 }
330 if a.dialog.HasDialogs() {
328 if a.dialog.HasDialogs() {
331− return nil // Don't open commands dialog if another dialog is active
329+ return nil
332 }
330 }
333 return util.CmdHandler(dialogs.OpenDialogMsg{
331 return util.CmdHandler(dialogs.OpenDialogMsg{
334 Model: commands.NewCommandDialog(a.selectedSessionID),
332 Model: commands.NewCommandDialog(a.selectedSessionID),
335 })
333 })
336 case key.Matches(msg, a.keyMap.Sessions):
334 case key.Matches(msg, a.keyMap.Sessions):
337 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
335 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
338− // If the sessions dialog is already open, close it
339 return util.CmdHandler(dialogs.CloseDialogMsg{})
336 return util.CmdHandler(dialogs.CloseDialogMsg{})
340 }
337 }
338+ if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
339+ return nil
340+ }
341 var cmds []tea.Cmd
341 var cmds []tea.Cmd
342 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
342 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
343 // If the commands dialog is open, close it first
343 // If the commands dialog is open, close it first