Detailed changes
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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:
@@ -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()
+}
@@ -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
@@ -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",
@@ -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"),
@@ -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)
@@ -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()}
+}
@@ -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)
@@ -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() {
@@ -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!")