From 4379251cfe71ddc892b80d3385a3b0b9e6ba54db Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 13 Jan 2026 11:36:27 +0100 Subject: [PATCH] Refactored permissions dialog (#1796) Co-authored-by: Ayman Bagabas --- internal/ui/chat/agent.go | 22 +- internal/ui/chat/bash.go | 22 +- internal/ui/chat/diagnostics.go | 6 +- internal/ui/chat/fetch.go | 18 +- internal/ui/chat/file.go | 28 +- internal/ui/chat/search.go | 24 +- internal/ui/chat/todos.go | 6 +- internal/ui/chat/tools.go | 137 +++--- internal/ui/common/scrollbar.go | 46 ++ internal/ui/dialog/actions.go | 5 + internal/ui/dialog/commands.go | 17 +- internal/ui/dialog/dialog.go | 12 + internal/ui/dialog/models.go | 14 +- internal/ui/dialog/permissions.go | 695 ++++++++++++++++++++++++++++++ internal/ui/dialog/sessions.go | 12 +- internal/ui/model/ui.go | 136 ++++++ internal/ui/styles/styles.go | 13 + 17 files changed, 1068 insertions(+), 145 deletions(-) create mode 100644 internal/ui/common/scrollbar.go create mode 100644 internal/ui/dialog/permissions.go diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index f13f9f03f69df57ac61406f69aa3fe30c22d8f07..c2a439ff23d0bd046b75076ea30de68b60cdcc54 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -47,14 +47,14 @@ func NewAgentToolMessageItem( t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) // For the agent tool we keep spinning until the tool call is finished. t.spinningFunc = func(state SpinningState) bool { - return state.Result == nil && !state.Canceled + return !state.HasResult() && !state.IsCanceled() } return t } // Animate progresses the message animation if it should be spinning. func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { - if a.result != nil || a.canceled { + if a.result != nil || a.Status() == ToolStatusCanceled { return nil } if msg.ID == a.ID() { @@ -100,7 +100,7 @@ type AgentToolRenderContext struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled && len(r.agent.nestedTools) == 0 { + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { return pendingTool(sty, "Agent", opts.Anim) } @@ -110,7 +110,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts prompt := params.Prompt prompt = strings.ReplaceAll(prompt, "\n", " ") - header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact) + header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) if opts.Compact { return header } @@ -149,14 +149,14 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String()) // Show animation if still running. - if opts.Result == nil && !opts.Canceled { + if !opts.HasResult() && !opts.IsCanceled() { parts = append(parts, "", opts.Anim.Render()) } result := lipgloss.JoinVertical(lipgloss.Left, parts...) // Add body content when completed. - if opts.Result != nil && opts.Result.Content != "" { + if opts.HasResult() && opts.Result.Content != "" { body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } @@ -191,7 +191,7 @@ func NewAgenticFetchToolMessageItem( t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) // For the agentic fetch tool we keep spinning until the tool call is finished. t.spinningFunc = func(state SpinningState) bool { - return state.Result == nil && !state.Canceled + return !state.HasResult() && !state.IsCanceled() } return t } @@ -231,7 +231,7 @@ type agenticFetchParams struct { // RenderTool implements the [ToolRenderer] interface. func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled && len(r.fetch.nestedTools) == 0 { + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { return pendingTool(sty, "Agentic Fetch", opts.Anim) } @@ -247,7 +247,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int toolParams = append(toolParams, params.URL) } - header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -286,14 +286,14 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String()) // Show animation if still running. - if opts.Result == nil && !opts.Canceled { + if !opts.HasResult() && !opts.IsCanceled() { parts = append(parts, "", opts.Anim.Render()) } result := lipgloss.JoinVertical(lipgloss.Left, parts...) // Add body content when completed. - if opts.Result != nil && opts.Result.Content != "" { + if opts.HasResult() && opts.Result.Content != "" { body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) return joinToolParts(result, body) } diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 0202780cf1e670b48cb7f9a8b9d27a0fe44f5405..18be27ee01b4fcc21749789fc65ec0b71c2b0d4b 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -40,7 +40,7 @@ type BashToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Bash", opts.Anim) } @@ -51,7 +51,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * // Check if this is a background job. var meta tools.BashResponseMetadata - if opts.Result != nil { + if opts.HasResult() { _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) } @@ -69,7 +69,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -78,7 +78,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -122,7 +122,7 @@ type JobOutputToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } @@ -132,7 +132,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o } var description string - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { var meta tools.JobOutputResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { description = cmp.Or(meta.Description, meta.Command) @@ -140,7 +140,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o } content := "" - if opts.Result != nil { + if opts.HasResult() { content = opts.Result.Content } return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) @@ -173,7 +173,7 @@ type JobKillToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Job", opts.Anim) } @@ -183,7 +183,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt } var description string - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { var meta tools.JobKillResponseMetadata if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { description = cmp.Or(meta.Description, meta.Command) @@ -191,7 +191,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt } content := "" - if opts.Result != nil { + if opts.HasResult() { content = opts.Result.Content } return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) @@ -200,7 +200,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // renderJobTool renders a job-related tool with the common pattern: // header → nested check → early state → body. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { - header := jobHeader(sty, opts.Status(), action, shellID, description, width) + header := jobHeader(sty, opts.Status, action, shellID, description, width) if opts.Compact { return header } diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 8ca5436b9082033a9cbb0debedffec041833ea11..68d2ac4a00dc880c27904468008fb8f6b2fcf9c5 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -36,7 +36,7 @@ type DiagnosticsToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Diagnostics", opts.Anim) } @@ -49,7 +49,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, mainParam = fsext.PrettyPath(params.FilePath) } - header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam) + header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam) if opts.Compact { return header } @@ -58,7 +58,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go index 41e35c90004a76337e8ce3d59908cadf32ed699f..e3f3a809550385dfd0ec557e98151ffc731acc93 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -35,7 +35,7 @@ type FetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } @@ -52,7 +52,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -61,7 +61,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -110,7 +110,7 @@ type WebFetchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Fetch", opts.Anim) } @@ -120,7 +120,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op } toolParams := []string{params.URL} - header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -129,7 +129,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -164,7 +164,7 @@ type WebSearchToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Search", opts.Anim) } @@ -174,7 +174,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o } toolParams := []string{params.Query} - header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -183,7 +183,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index ca0e0b4934e806bbed0c7826161bb2c91a10843f..d558f79d597871bf6074d33c76b44549ee6725d5 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -38,7 +38,7 @@ type ViewToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "View", opts.Anim) } @@ -56,7 +56,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) } - header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -65,7 +65,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -118,7 +118,7 @@ type WriteToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Write", opts.Anim) } @@ -128,7 +128,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) if opts.Compact { return header } @@ -173,7 +173,7 @@ type EditToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // Edit tool uses full width for diffs. - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Edit", opts.Anim) } @@ -183,7 +183,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file) + header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file) if opts.Compact { return header } @@ -192,7 +192,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -236,7 +236,7 @@ type MultiEditToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // MultiEdit tool uses full width for diffs. - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Multi-Edit", opts.Anim) } @@ -251,7 +251,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) } - header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...) if opts.Compact { return header } @@ -260,7 +260,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o return joinToolParts(header, earlyState) } - if opts.Result == nil { + if !opts.HasResult() { return header } @@ -304,7 +304,7 @@ type DownloadToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Download", opts.Anim) } @@ -321,7 +321,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) } - header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -330,7 +330,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 3430d7d5c8aebe6e93979284f659e74b60316ca9..2342f671fdaed3bfdcf56619864bd3b60987d8a6 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -36,7 +36,7 @@ type GlobToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Glob", opts.Anim) } @@ -50,7 +50,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -59,7 +59,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if !opts.HasResult() || opts.Result.Content == "" { return header } @@ -95,7 +95,7 @@ type GrepToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Grep", opts.Anim) } @@ -115,7 +115,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -124,7 +124,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -160,7 +160,7 @@ type LSToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "List", opts.Anim) } @@ -175,7 +175,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path) + header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path) if opts.Compact { return header } @@ -184,7 +184,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } @@ -220,7 +220,7 @@ type SourcegraphToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "Sourcegraph", opts.Anim) } @@ -237,7 +237,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) } - header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } @@ -246,7 +246,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, return joinToolParts(header, earlyState) } - if opts.Result == nil || opts.Result.Content == "" { + if opts.HasEmptyResult() { return header } diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index 3f92de9b32287270298b8a20c463850a32d110b5..f34e10a093b2b66d4b9993237fdbfe94fb53ecfb 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -40,7 +40,7 @@ type TodosToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - if !opts.ToolCall.Finished && !opts.Canceled { + if opts.IsPending() { return pendingTool(sty, "To-Do", opts.Anim) } @@ -74,7 +74,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } // If we have metadata, use it for richer display. - if opts.Result != nil && opts.Result.Metadata != "" { + if opts.HasResult() && opts.Result.Metadata != "" { if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { if meta.IsNew { if meta.JustStarted != "" { @@ -119,7 +119,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } toolParams := []string{headerText} - header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...) + header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...) if opts.Compact { return header } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 016fb1dc32ab81042623cf6750666eba1d42ecd9..5c12279e50af551d8b1686afefb3cc52feda4c6d 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -42,6 +42,8 @@ type ToolMessageItem interface { SetResult(res *message.ToolResult) MessageID() string SetMessageID(id string) + SetStatus(status ToolStatus) + Status() ToolStatus } // Compactable is an interface for tool items that can render in a compacted mode. @@ -54,7 +56,17 @@ type Compactable interface { type SpinningState struct { ToolCall message.ToolCall Result *message.ToolResult - Canceled bool + Status ToolStatus +} + +// IsCanceled returns true if the tool status is canceled. +func (s *SpinningState) IsCanceled() bool { + return s.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (s *SpinningState) HasResult() bool { + return s.Result != nil } // SpinningFunc is a function type for custom spinning logic. @@ -71,32 +83,34 @@ func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // ToolRenderOpts contains the data needed to render a tool call. type ToolRenderOpts struct { - ToolCall message.ToolCall - Result *message.ToolResult - Canceled bool - Anim *anim.Anim - ExpandedContent bool - Compact bool - IsSpinning bool - PermissionRequested bool - PermissionGranted bool -} - -// Status returns the current status of the tool call. -func (opts *ToolRenderOpts) Status() ToolStatus { - if opts.Canceled && opts.Result == nil { - return ToolStatusCanceled - } - if opts.Result != nil { - if opts.Result.IsError { - return ToolStatusError - } - return ToolStatusSuccess - } - if opts.PermissionRequested && !opts.PermissionGranted { - return ToolStatusAwaitingPermission - } - return ToolStatusRunning + ToolCall message.ToolCall + Result *message.ToolResult + Anim *anim.Anim + ExpandedContent bool + Compact bool + IsSpinning bool + Status ToolStatus +} + +// IsPending returns true if the tool call is still pending (not finished and +// not canceled). +func (o *ToolRenderOpts) IsPending() bool { + return !o.ToolCall.Finished && !o.IsCanceled() +} + +// IsCanceled returns true if the tool status is canceled. +func (o *ToolRenderOpts) IsCanceled() bool { + return o.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (o *ToolRenderOpts) HasResult() bool { + return o.Result != nil +} + +// HasEmptyResult returns true if the result is nil or has empty content. +func (o *ToolRenderOpts) HasEmptyResult() bool { + return o.Result == nil || o.Result.Content == "" } // ToolRenderer represents an interface for rendering tool calls. @@ -118,13 +132,11 @@ type baseToolMessageItem struct { *cachedMessageItem *focusableMessageItem - toolRenderer ToolRenderer - toolCall message.ToolCall - result *message.ToolResult - messageID string - canceled bool - permissionRequested bool - permissionGranted bool + toolRenderer ToolRenderer + toolCall message.ToolCall + result *message.ToolResult + messageID string + status ToolStatus // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool @@ -150,6 +162,11 @@ func newBaseToolMessageItem( // we only do full width for diffs (as far as I know) hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName + status := ToolStatusRunning + if canceled { + status = ToolStatusCanceled + } + t := &baseToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, @@ -158,7 +175,7 @@ func newBaseToolMessageItem( toolRenderer: toolRenderer, toolCall: toolCall, result: result, - canceled: canceled, + status: status, hasCappedWidth: hasCappedWidth, } t.anim = anim.New(anim.Settings{ @@ -285,15 +302,13 @@ func (t *baseToolMessageItem) Render(width int) string { // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ - ToolCall: t.toolCall, - Result: t.result, - Canceled: t.canceled, - Anim: t.anim, - ExpandedContent: t.expandedContent, - Compact: t.isCompact, - PermissionRequested: t.permissionRequested, - PermissionGranted: t.permissionGranted, - IsSpinning: t.isSpinning(), + ToolCall: t.toolCall, + Result: t.result, + Anim: t.anim, + ExpandedContent: t.expandedContent, + Compact: t.isCompact, + IsSpinning: t.isSpinning(), + Status: t.computeStatus(), }) height = lipgloss.Height(content) // cache the rendered content @@ -331,20 +346,26 @@ func (t *baseToolMessageItem) SetMessageID(id string) { t.messageID = id } -// SetPermissionRequested sets whether permission has been requested for this tool call. -// TODO: Consider merging with SetPermissionGranted and add an interface for -// permission management. -func (t *baseToolMessageItem) SetPermissionRequested(requested bool) { - t.permissionRequested = requested +// SetStatus sets the tool status. +func (t *baseToolMessageItem) SetStatus(status ToolStatus) { + t.status = status t.clearCache() } -// SetPermissionGranted sets whether permission has been granted for this tool call. -// TODO: Consider merging with SetPermissionRequested and add an interface for -// permission management. -func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { - t.permissionGranted = granted - t.clearCache() +// Status returns the current tool status. +func (t *baseToolMessageItem) Status() ToolStatus { + return t.status +} + +// computeStatus computes the effective status considering the result. +func (t *baseToolMessageItem) computeStatus() ToolStatus { + if t.result != nil { + if t.result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + return t.status } // isSpinning returns true if the tool should show animation. @@ -353,10 +374,10 @@ func (t *baseToolMessageItem) isSpinning() bool { return t.spinningFunc(SpinningState{ ToolCall: t.toolCall, Result: t.result, - Canceled: t.canceled, + Status: t.status, }) } - return !t.toolCall.Finished && !t.canceled + return !t.toolCall.Finished && t.status != ToolStatusCanceled } // SetSpinningFunc sets a custom function to determine if the tool should spin. @@ -396,7 +417,7 @@ func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { // Returns the rendered output and true if early state was handled. func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { var msg string - switch opts.Status() { + switch opts.Status { case ToolStatusError: msg = toolErrorContent(sty, opts.Result, width) case ToolStatusCanceled: diff --git a/internal/ui/common/scrollbar.go b/internal/ui/common/scrollbar.go new file mode 100644 index 0000000000000000000000000000000000000000..7e701659348c90100534c18620f5e9949db3d050 --- /dev/null +++ b/internal/ui/common/scrollbar.go @@ -0,0 +1,46 @@ +package common + +import ( + "strings" + + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// Scrollbar renders a vertical scrollbar based on content and viewport size. +// Returns an empty string if content fits within viewport (no scrolling needed). +func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string { + if height <= 0 || contentSize <= viewportSize { + return "" + } + + // Calculate thumb size (minimum 1 character). + thumbSize := max(1, height*viewportSize/contentSize) + + // Calculate thumb position. + maxOffset := contentSize - viewportSize + if maxOffset <= 0 { + return "" + } + + // Calculate where the thumb starts. + trackSpace := height - thumbSize + thumbPos := 0 + if trackSpace > 0 && maxOffset > 0 { + thumbPos = min(trackSpace, offset*trackSpace/maxOffset) + } + + // Build the scrollbar. + var sb strings.Builder + for i := range height { + if i > 0 { + sb.WriteString("\n") + } + if i >= thumbPos && i < thumbPos+thumbSize { + sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)) + } else { + sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)) + } + } + + return sb.String() +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a152a125b179e7f14fd69361f236ce9d76e8effa..a9b785eaf1ce7a2d11b24245bd3c51b166da680b 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -3,6 +3,7 @@ package dialog import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" ) @@ -39,6 +40,10 @@ type ( ActionSummarize struct { SessionID string } + ActionPermissionResponse struct { + Permission permission.PermissionRequest + Action PermissionAction + } ) // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index ec62320909445f96e3063747f6fea3d77628e6e4..8211016b95fb1e71b5cb64699d2d0fd12930ee84 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -220,16 +220,15 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm // Draw implements [Dialog]. func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles - width := max(0, min(100, area.Dx())) - height := max(0, min(30, area.Dy())) + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) c.width = width - // TODO: Why do we need this 2? - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() + c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) @@ -416,7 +415,7 @@ func (c *Commands) defaultCommands() []uicmd.Command { cfg := c.com.Config() agentCfg := cfg.Agents[config.AgentCoder] model := cfg.GetModelByType(agentCfg.Model) - if model.SupportsImages { + if model != nil && model.SupportsImages { commands = append(commands, uicmd.Command{ ID: "file_picker", Title: "Open File Picker", diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index f51ff0a4ee8390c8b889ccb1f3f3c2ba60c39532..68eb313d4ec83cf8d098fcfccb5ebf27de8bd0d1 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -8,6 +8,18 @@ import ( uv "github.com/charmbracelet/ultraviolet" ) +// Dialog sizing constants. +const ( + // defaultDialogMaxWidth is the maximum width for standard dialogs. + defaultDialogMaxWidth = 120 + // defaultDialogHeight is the default height for standard dialogs. + defaultDialogHeight = 30 + // titleContentHeight is the height of the title content line. + titleContentHeight = 1 + // inputContentHeight is the height of the input content line. + inputContentHeight = 1 +) + // CloseKey is the default key binding to close dialogs. var CloseKey = key.NewBinding( key.WithKeys("esc", "alt+esc"), diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 344db9cd3a2dbf96f73465d66449d2f60a9df7c3..41d0efe8d21d0dce6fc6ace138a88304dea1123a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -241,15 +241,13 @@ func (m *Models) modelTypeRadioView() string { // Draw implements [Dialog]. func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles - width := max(0, min(60, area.Dx())) - height := max(0, min(30, area.Dy())) - // TODO: Why do we need this 2? - innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..87d592807f578d452c7a8f3a28931847426b8f62 --- /dev/null +++ b/internal/ui/dialog/permissions.go @@ -0,0 +1,695 @@ +package dialog + +import ( + "encoding/json" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// PermissionsID is the identifier for the permissions dialog. +const PermissionsID = "permissions" + +// PermissionAction represents the user's response to a permission request. +type PermissionAction string + +const ( + PermissionAllow PermissionAction = "allow" + PermissionAllowForSession PermissionAction = "allow_session" + PermissionDeny PermissionAction = "deny" +) + +// Permissions dialog sizing constants. +const ( + // diffMaxWidth is the maximum width for diff views. + diffMaxWidth = 180 + // diffSizeRatio is the size ratio for diff views relative to window. + diffSizeRatio = 0.8 + // simpleMaxWidth is the maximum width for simple content dialogs. + simpleMaxWidth = 100 + // simpleSizeRatio is the size ratio for simple content dialogs. + simpleSizeRatio = 0.6 + // simpleHeightRatio is the height ratio for simple content dialogs. + simpleHeightRatio = 0.5 + // splitModeMinWidth is the minimum width to enable split diff mode. + splitModeMinWidth = 140 + // layoutSpacingLines is the number of empty lines used for layout spacing. + layoutSpacingLines = 4 + // minWindowWidth is the minimum window width before forcing fullscreen. + minWindowWidth = 60 + // minWindowHeight is the minimum window height before forcing fullscreen. + minWindowHeight = 20 +) + +// Permissions represents a dialog for permission requests. +type Permissions struct { + com *common.Common + windowWidth int // Terminal window dimensions. + windowHeight int + fullscreen bool // true when dialog is fullscreen + + permission permission.PermissionRequest + selectedOption int // 0: Allow, 1: Allow for session, 2: Deny + + viewport viewport.Model + viewportDirty bool // true when viewport content needs to be re-rendered + viewportWidth int + + // Diff view state. + diffSplitMode *bool // nil means use default based on width + defaultDiffSplitMode bool // default split mode based on width + unifiedDiffContent string + splitDiffContent string + + help help.Model + keyMap permissionsKeyMap +} + +type permissionsKeyMap struct { + Left key.Binding + Right key.Binding + Tab key.Binding + Select key.Binding + Allow key.Binding + AllowSession key.Binding + Deny key.Binding + Close key.Binding + ToggleDiffMode key.Binding + ToggleFullscreen key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Choose key.Binding + Scroll key.Binding +} + +func defaultPermissionsKeyMap() permissionsKeyMap { + return permissionsKeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←", "previous"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→", "next"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next option"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Allow: key.NewBinding( + key.WithKeys("a", "A", "ctrl+a"), + key.WithHelp("a", "allow"), + ), + AllowSession: key.NewBinding( + key.WithKeys("s", "S", "ctrl+s"), + key.WithHelp("s", "allow session"), + ), + Deny: key.NewBinding( + key.WithKeys("d", "D"), + key.WithHelp("d", "deny"), + ), + Close: CloseKey, + ToggleDiffMode: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "toggle diff view"), + ), + ToggleFullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle fullscreen"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "scroll down"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("shift+left", "H"), + key.WithHelp("shift+←", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("shift+right", "L"), + key.WithHelp("shift+→", "scroll right"), + ), + Choose: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "choose"), + ), + Scroll: key.NewBinding( + key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), + key.WithHelp("shift+←↓↑→", "scroll"), + ), + } +} + +var _ Dialog = (*Permissions)(nil) + +// PermissionsOption configures the permissions dialog. +type PermissionsOption func(*Permissions) + +// WithDiffMode sets the initial diff mode (split or unified). +func WithDiffMode(split bool) PermissionsOption { + return func(p *Permissions) { + p.diffSplitMode = &split + } +} + +// NewPermissions creates a new permissions dialog. +func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions { + h := help.New() + h.Styles = com.Styles.DialogHelpStyles() + + km := defaultPermissionsKeyMap() + + // Configure viewport with matching keybindings. + vp := viewport.New() + vp.KeyMap = viewport.KeyMap{ + Up: km.ScrollUp, + Down: km.ScrollDown, + Left: km.ScrollLeft, + Right: km.ScrollRight, + // Disable other viewport keys to avoid conflicts with dialog shortcuts. + PageUp: key.NewBinding(key.WithDisabled()), + PageDown: key.NewBinding(key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithDisabled()), + HalfPageDown: key.NewBinding(key.WithDisabled()), + } + + p := &Permissions{ + com: com, + permission: perm, + selectedOption: 0, + viewport: vp, + help: h, + keyMap: km, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// Calculate usable content width (dialog border + horizontal padding). +func (p *Permissions) calculateContentWidth(width int) int { + t := p.com.Styles + const dialogHorizontalPadding = 2 + return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding +} + +// ID implements [Dialog]. +func (*Permissions) ID() string { + return PermissionsID +} + +// HandleMsg implements [Dialog]. +func (p *Permissions) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.Close): + // Escape denies the permission request. + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab): + p.selectedOption = (p.selectedOption + 1) % 3 + case key.Matches(msg, p.keyMap.Left): + // Add 2 instead of subtracting 1 to avoid negative modulo. + p.selectedOption = (p.selectedOption + 2) % 3 + case key.Matches(msg, p.keyMap.Select): + return p.selectCurrentOption() + case key.Matches(msg, p.keyMap.Allow): + return p.respond(PermissionAllow) + case key.Matches(msg, p.keyMap.AllowSession): + return p.respond(PermissionAllowForSession) + case key.Matches(msg, p.keyMap.Deny): + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.ToggleDiffMode): + if p.hasDiffView() { + newMode := !p.isSplitMode() + p.diffSplitMode = &newMode + p.viewportDirty = true + } + case key.Matches(msg, p.keyMap.ToggleFullscreen): + if p.hasDiffView() { + p.fullscreen = !p.fullscreen + } + case key.Matches(msg, p.keyMap.ScrollDown): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollUp): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollLeft): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollRight): + p.viewport, _ = p.viewport.Update(msg) + } + case tea.MouseWheelMsg: + p.viewport, _ = p.viewport.Update(msg) + default: + // Pass unhandled keys to viewport for non-diff content scrolling. + if !p.hasDiffView() { + p.viewport, _ = p.viewport.Update(msg) + p.viewportDirty = true + } + } + + return nil +} + +func (p *Permissions) selectCurrentOption() tea.Msg { + switch p.selectedOption { + case 0: + return p.respond(PermissionAllow) + case 1: + return p.respond(PermissionAllowForSession) + default: + return p.respond(PermissionDeny) + } +} + +func (p *Permissions) respond(action PermissionAction) tea.Msg { + return ActionPermissionResponse{ + Permission: p.permission, + Action: action, + } +} + +func (p *Permissions) hasDiffView() bool { + switch p.permission.ToolName { + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName: + return true + } + return false +} + +func (p *Permissions) isSplitMode() bool { + if p.diffSplitMode != nil { + return *p.diffSplitMode + } + return p.defaultDiffSplitMode +} + +// Draw implements [Dialog]. +func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := p.com.Styles + // Force fullscreen when window is too small. + forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight + + // Calculate dialog dimensions based on fullscreen state and content type. + var width, height int + if forceFullscreen || (p.fullscreen && p.hasDiffView()) { + // Use nearly full window for fullscreen. + width = area.Dx() + height = area.Dy() + } else if p.hasDiffView() { + // Wide for side-by-side diffs, capped for readability. + width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth) + height = int(float64(area.Dy()) * diffSizeRatio) + } else { + // Narrower for simple content like commands/URLs. + width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth) + height = int(float64(area.Dy()) * simpleHeightRatio) + } + + dialogStyle := t.Dialog.View.Width(width).Padding(0, 1) + + contentWidth := p.calculateContentWidth(width) + header := p.renderHeader(contentWidth) + buttons := p.renderButtons(contentWidth) + helpView := p.help.View(p) + + // Calculate available height for content. + headerHeight := lipgloss.Height(header) + buttonsHeight := lipgloss.Height(buttons) + helpHeight := lipgloss.Height(helpView) + frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines + availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight + + p.defaultDiffSplitMode = width >= splitModeMinWidth + + if p.viewport.Width() != contentWidth-1 { + // Mark diff content as dirty if width has changed + p.viewportDirty = true + } + + var content string + var scrollbar string + // Non-diff content uses the viewport for scrolling. + p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar + p.viewport.SetHeight(availableHeight) + if p.viewportDirty { + p.viewport.SetContent(p.renderContent(contentWidth - 1)) + p.viewportWidth = p.viewport.Width() + p.viewportDirty = false + } + content = p.viewport.View() + if p.canScroll() { + scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset()) + } + + // Join content with scrollbar if present. + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + + parts := []string{header} + if content != "" { + parts = append(parts, "", content) + } + parts = append(parts, "", buttons, "", helpView) + + innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...) + DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil) + return nil +} + +func (p *Permissions) renderHeader(contentWidth int) string { + t := p.com.Styles + + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize()) + title = t.Dialog.Title.Render(title) + + // Tool info. + toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth) + pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth) + + lines := []string{title, "", toolLine, pathLine} + + // Add tool-specific header info. + switch p.permission.ToolName { + case tools.BashToolName: + if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth)) + } + case tools.DownloadToolName: + if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth)) + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth)) + } + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: + var filePath string + switch params := p.permission.Params.(type) { + case tools.EditPermissionsParams: + filePath = params.FilePath + case tools.WritePermissionsParams: + filePath = params.FilePath + case tools.MultiEditPermissionsParams: + filePath = params.FilePath + case tools.ViewPermissionsParams: + filePath = params.FilePath + } + if filePath != "" { + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth)) + } + case tools.LSToolName: + if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth)) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (p *Permissions) renderKeyValue(key, value string, width int) string { + t := p.com.Styles + keyStyle := t.Muted + valueStyle := t.Base + + keyStr := keyStyle.Render(key) + valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value) + + return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr) +} + +func (p *Permissions) renderContent(width int) string { + switch p.permission.ToolName { + case tools.BashToolName: + return p.renderBashContent() + case tools.EditToolName: + return p.renderEditContent(width) + case tools.WriteToolName: + return p.renderWriteContent(width) + case tools.MultiEditToolName: + return p.renderMultiEditContent(width) + case tools.DownloadToolName: + return p.renderDownloadContent() + case tools.FetchToolName: + return p.renderFetchContent() + case tools.AgenticFetchToolName: + return p.renderAgenticFetchContent() + case tools.ViewToolName: + return p.renderViewContent() + case tools.LSToolName: + return p.renderLSContent() + default: + return p.renderDefaultContent() + } +} + +func (p *Permissions) renderBashContent() string { + params, ok := p.permission.Params.(tools.BashPermissionsParams) + if !ok { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(params.Command) +} + +func (p *Permissions) renderEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.EditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderWriteContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.WritePermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderMultiEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.MultiEditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string { + if !p.viewportDirty { + if p.isSplitMode() { + return p.splitDiffContent + } + return p.unifiedDiffContent + } + + isSplitMode := p.isSplitMode() + formatter := common.DiffFormatter(p.com.Styles). + Before(fsext.PrettyPath(filePath), oldContent). + After(fsext.PrettyPath(filePath), newContent). + // TODO: Allow horizontal scrolling instead of cropping. However, the + // diffview currently would only background color the width of the + // content. If the viewport is wider than the content, the rest of the + // line would not be colored properly. + Width(contentWidth) + + var result string + if isSplitMode { + formatter = formatter.Split() + p.splitDiffContent = formatter.String() + result = p.splitDiffContent + } else { + formatter = formatter.Unified() + p.unifiedDiffContent = formatter.String() + result = p.unifiedDiffContent + } + + return result +} + +func (p *Permissions) renderDownloadContent() string { + params, ok := p.permission.Params.(tools.DownloadPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath)) + if params.Timeout > 0 { + content += fmt.Sprintf("\nTimeout: %ds", params.Timeout) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderFetchContent() string { + params, ok := p.permission.Params.(tools.FetchPermissionsParams) + if !ok { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(params.URL) +} + +func (p *Permissions) renderAgenticFetchContent() string { + params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams) + if !ok { + return "" + } + + var content string + if params.URL != "" { + content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt) + } else { + content = fmt.Sprintf("Prompt: %s", params.Prompt) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderViewContent() string { + params, ok := p.permission.Params.(tools.ViewPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath)) + if params.Offset > 0 { + content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1) + } + if params.Limit > 0 && params.Limit != 2000 { + content += fmt.Sprintf("\nLines to read: %d", params.Limit) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderLSContent() string { + params, ok := p.permission.Params.(tools.LSPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path)) + if len(params.Ignore) > 0 { + content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", ")) + } + + return p.com.Styles.Dialog.ContentPanel.Render(content) +} + +func (p *Permissions) renderDefaultContent() string { + content := p.permission.Description + + // Pretty-print JSON params if available. + if p.permission.Params != nil { + var paramStr string + if str, ok := p.permission.Params.(string); ok { + paramStr = str + } else { + paramStr = fmt.Sprintf("%v", p.permission.Params) + } + + var parsed any + if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { + if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + if content != "" { + content += "\n\n" + } + content += string(b) + } + } else if paramStr != "" { + if content != "" { + content += "\n\n" + } + content += paramStr + } + } + + if content == "" { + return "" + } + + return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content)) +} + +func (p *Permissions) renderButtons(contentWidth int) string { + buttons := []common.ButtonOpts{ + {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0}, + {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1}, + {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2}, + } + + content := common.ButtonGroup(p.com.Styles, buttons, " ") + + // If buttons are too wide, stack them vertically. + if lipgloss.Width(content) > contentWidth { + content = common.ButtonGroup(p.com.Styles, buttons, "\n") + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Center). + Render(content) + } + + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Right). + Render(content) +} + +func (p *Permissions) canScroll() bool { + if p.hasDiffView() { + // Diff views can always scroll. + return true + } + // For non-diff content, check if viewport has scrollable content. + return !p.viewport.AtTop() || !p.viewport.AtBottom() +} + +// ShortHelp implements [help.KeyMap]. +func (p *Permissions) ShortHelp() []key.Binding { + bindings := []key.Binding{ + p.keyMap.Choose, + p.keyMap.Select, + p.keyMap.Close, + } + + if p.canScroll() { + bindings = append(bindings, p.keyMap.Scroll) + } + + if p.hasDiffView() { + bindings = append(bindings, + p.keyMap.ToggleDiffMode, + p.keyMap.ToggleFullscreen, + ) + } + + return bindings +} + +// FullHelp implements [help.KeyMap]. +func (p *Permissions) FullHelp() [][]key.Binding { + return [][]key.Binding{p.ShortHelp()} +} diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 3773eba6c612d824c57f1886cd017a203bdca9d5..7a4725fcb9fac33d349dd5d6d7812e8f70c00eaa 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -143,15 +143,13 @@ func (s *Session) Cursor() *tea.Cursor { // Draw implements [Dialog]. func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := s.com.Styles - width := max(0, min(120, area.Dx())) - height := max(0, min(30, area.Dy())) - // TODO: Why do we need this 2? + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 - heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content - t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + t.Dialog.HelpView.GetVerticalFrameSize() + - // TODO: Why do we need this 2? - t.Dialog.View.GetVerticalFrameSize() + 2 + t.Dialog.View.GetVerticalFrameSize() s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index adfb20c4be53946ce666719b5b70c417064eb3d0..2c32c6774a6a83dbb2c858f97239a00521f5fd38 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -13,6 +13,7 @@ import ( "slices" "strconv" "strings" + "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -74,6 +75,8 @@ type openEditorMsg struct { Text string } +type cancelTimerExpiredMsg struct{} + // UI represents the main user interface model. type UI struct { com *common.Common @@ -94,6 +97,9 @@ type UI struct { dialog *dialog.Overlay status *Status + // isCanceling tracks whether the user has pressed escape once to cancel. + isCanceling bool + // header is the last cached header logo header string @@ -280,6 +286,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case pubsub.Event[permission.PermissionRequest]: + if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } + case pubsub.Event[permission.PermissionNotification]: + m.handlePermissionNotification(msg.Payload) + case cancelTimerExpiredMsg: + m.isCanceling = false case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -348,6 +362,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chat.HandleMouseUp(x, y) } case tea.MouseWheelMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + // Otherwise handle mouse wheel for chat. switch m.state { case uiChat: switch msg.Button { @@ -778,6 +799,17 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) cmds = append(cmds, uiutil.ReportInfo(modelMsg)) m.dialog.CloseDialog(dialog.ModelsID) + // TODO CHANGE + case dialog.ActionPermissionResponse: + m.dialog.CloseDialog(dialog.PermissionsID) + switch msg.Action { + case dialog.PermissionAllow: + m.com.App.Permissions.Grant(msg.Permission) + case dialog.PermissionAllowForSession: + m.com.App.Permissions.GrantPersistent(msg.Permission) + case dialog.PermissionDeny: + m.com.App.Permissions.Deny(msg.Permission) + } } return tea.Batch(cmds...) @@ -825,6 +857,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return m.handleDialogMsg(msg) } + // Handle cancel key when agent is busy. + if key.Matches(msg, m.keyMap.Chat.Cancel) { + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + if cmd := m.cancelAgent(); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) + } + } + switch m.state { case uiConfigure: return tea.Batch(cmds...) @@ -1207,6 +1249,17 @@ func (m *UI) ShortHelp() []key.Binding { case uiInitialize: binds = append(binds, k.Quit) case uiChat: + // Show cancel binding if agent is busy. + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, cancelBinding) + } + if m.focus == uiFocusEditor { tab.SetHelp("tab", "focus chat") } else { @@ -1272,6 +1325,17 @@ func (m *UI) FullHelp() [][]key.Binding { k.Quit, }) case uiChat: + // Show cancel binding if agent is busy. + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, []key.Binding{cancelBinding}) + } + mainBinds := []key.Binding{} tab := k.Tab if m.focus == uiFocusEditor { @@ -1800,6 +1864,46 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return tea.Batch(cmds...) } +const cancelTimerDuration = 2 * time.Second + +// cancelTimerCmd creates a command that expires the cancel timer. +func cancelTimerCmd() tea.Cmd { + return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg { + return cancelTimerExpiredMsg{} + }) +} + +// cancelAgent handles the cancel key press. The first press sets isCanceling to true +// and starts a timer. The second press (before the timer expires) actually +// cancels the agent. +func (m *UI) cancelAgent() tea.Cmd { + if m.session == nil || m.session.ID == "" { + return nil + } + + coordinator := m.com.App.AgentCoordinator + if coordinator == nil { + return nil + } + + if m.isCanceling { + // Second escape press - actually cancel the agent. + m.isCanceling = false + coordinator.Cancel(m.session.ID) + return nil + } + + // Check if there are queued prompts - if so, clear the queue. + if coordinator.QueuedPrompts(m.session.ID) > 0 { + coordinator.ClearQueue(m.session.ID) + return nil + } + + // First escape press - set canceling state and start timer. + m.isCanceling = true + return cancelTimerCmd() +} + // openDialog opens a dialog by its ID. func (m *UI) openDialog(id string) tea.Cmd { var cmds []tea.Cmd @@ -1906,6 +2010,38 @@ func (m *UI) openSessionsDialog() tea.Cmd { return nil } +// openPermissionsDialog opens the permissions dialog for a permission request. +func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { + // Close any existing permissions dialog first. + m.dialog.CloseDialog(dialog.PermissionsID) + + // Get diff mode from config. + var opts []dialog.PermissionsOption + if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" { + opts = append(opts, dialog.WithDiffMode(diffMode == "split")) + } + + permDialog := dialog.NewPermissions(m.com, perm, opts...) + m.dialog.OpenDialog(permDialog) + return nil +} + +// handlePermissionNotification updates tool items when permission state changes. +func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) { + toolItem := m.chat.MessageItem(notification.ToolCallID) + if toolItem == nil { + return + } + + if permItem, ok := toolItem.(chat.ToolMessageItem); ok { + if notification.Granted { + permItem.SetStatus(chat.ToolStatusRunning) + } else { + permItem.SetStatus(chat.ToolStatusAwaitingPermission) + } + } +} + // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. func (m *UI) newSession() { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 598ca2d0f0216c1d04ac99424e30cc07b56b39a0..12cfd52124fa8fa45488dd34d8556072372f4d8f 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -45,6 +45,9 @@ const ( ImageIcon string = "■" TextIcon string = "≡" + + ScrollbarThumb string = "┃" + ScrollbarTrack string = "│" ) const ( @@ -312,6 +315,13 @@ type Styles struct { List lipgloss.Style + // ContentPanel is used for content blocks with subtle background. + ContentPanel lipgloss.Style + + // Scrollbar styles for scrollable content. + ScrollbarThumb lipgloss.Style + ScrollbarTrack lipgloss.Style + Commands struct{} } @@ -1162,6 +1172,9 @@ func DefaultStyles() Styles { s.Dialog.InputPrompt = base.Margin(1, 1) s.Dialog.List = base.Margin(0, 0, 1, 0) + s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2) + s.Dialog.ScrollbarThumb = base.Foreground(secondary) + s.Dialog.ScrollbarTrack = base.Foreground(border) s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")