on main help sign in
Refactored permissions dialog (#1796)

Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>

commit4379251cfe71ddc892b80d3385a3b0b9e6ba54db authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 13 Jan 2026, 11:36 (9 months ago) parent9442ae7
17 files changed+1068−145
internal/ui/chat/agent.go+11−11 internal/ui/chat/bash.go+11−11 internal/ui/chat/diagnostics.go+3−3 internal/ui/chat/fetch.go+9−9 internal/ui/chat/file.go+14−14 internal/ui/chat/search.go+12−12 internal/ui/chat/todos.go+3−3 internal/ui/chat/tools.go+79−58 internal/ui/common/scrollbar.go+46 internal/ui/dialog/actions.go+5 internal/ui/dialog/commands.go+8−9 internal/ui/dialog/dialog.go+12 internal/ui/dialog/models.go+6−8 internal/ui/dialog/permissions.go+695 internal/ui/dialog/sessions.go+5−7 internal/ui/model/ui.go+136 internal/ui/styles/styles.go+13
internal/ui/chat/agent.go+11−11
@@ -47,14 +47,14 @@ func NewAgentToolMessageItem(
4747 t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
4848 // For the agent tool we keep spinning until the tool call is finished.
4949 t.spinningFunc = func(state SpinningState) bool {
50− return state.Result == nil && !state.Canceled
50+ return !state.HasResult() && !state.IsCanceled()
5151 }
5252 return t
5353}
5454
5555// Animate progresses the message animation if it should be spinning.
5656func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
57− if a.result != nil || a.canceled {
57+ if a.result != nil || a.Status() == ToolStatusCanceled {
5858 return nil
5959 }
6060 if msg.ID == a.ID() {
@@ -100,7 +100,7 @@ type AgentToolRenderContext struct {
100100// RenderTool implements the [ToolRenderer] interface.
101101func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
102102 cappedWidth := cappedMessageWidth(width)
103− if !opts.ToolCall.Finished && !opts.Canceled && len(r.agent.nestedTools) == 0 {
103+ if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
104104 return pendingTool(sty, "Agent", opts.Anim)
105105 }
106106
@@ -110,7 +110,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
110110 prompt := params.Prompt
111111 prompt = strings.ReplaceAll(prompt, "\n", " ")
112112
113− header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact)
113+ header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
114114 if opts.Compact {
115115 return header
116116 }
@@ -149,14 +149,14 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
149149 parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
150150
151151 // Show animation if still running.
152− if opts.Result == nil && !opts.Canceled {
152+ if !opts.HasResult() && !opts.IsCanceled() {
153153 parts = append(parts, "", opts.Anim.Render())
154154 }
155155
156156 result := lipgloss.JoinVertical(lipgloss.Left, parts...)
157157
158158 // Add body content when completed.
159− if opts.Result != nil && opts.Result.Content != "" {
159+ if opts.HasResult() && opts.Result.Content != "" {
160160 body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
161161 return joinToolParts(result, body)
162162 }
@@ -191,7 +191,7 @@ func NewAgenticFetchToolMessageItem(
191191 t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
192192 // For the agentic fetch tool we keep spinning until the tool call is finished.
193193 t.spinningFunc = func(state SpinningState) bool {
194− return state.Result == nil && !state.Canceled
194+ return !state.HasResult() && !state.IsCanceled()
195195 }
196196 return t
197197}
@@ -231,7 +231,7 @@ type agenticFetchParams struct {
231231// RenderTool implements the [ToolRenderer] interface.
232232func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
233233 cappedWidth := cappedMessageWidth(width)
234− if !opts.ToolCall.Finished && !opts.Canceled && len(r.fetch.nestedTools) == 0 {
234+ if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
235235 return pendingTool(sty, "Agentic Fetch", opts.Anim)
236236 }
237237
@@ -247,7 +247,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
247247 toolParams = append(toolParams, params.URL)
248248 }
249249
250− header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
250+ header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
251251 if opts.Compact {
252252 return header
253253 }
@@ -286,14 +286,14 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
286286 parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
287287
288288 // Show animation if still running.
289− if opts.Result == nil && !opts.Canceled {
289+ if !opts.HasResult() && !opts.IsCanceled() {
290290 parts = append(parts, "", opts.Anim.Render())
291291 }
292292
293293 result := lipgloss.JoinVertical(lipgloss.Left, parts...)
294294
295295 // Add body content when completed.
296− if opts.Result != nil && opts.Result.Content != "" {
296+ if opts.HasResult() && opts.Result.Content != "" {
297297 body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
298298 return joinToolParts(result, body)
299299 }
internal/ui/chat/bash.go+11−11
@@ -40,7 +40,7 @@ type BashToolRenderContext struct{}
4040// RenderTool implements the [ToolRenderer] interface.
4141func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
4242 cappedWidth := cappedMessageWidth(width)
43− if !opts.ToolCall.Finished && !opts.Canceled {
43+ if opts.IsPending() {
4444 return pendingTool(sty, "Bash", opts.Anim)
4545 }
4646
@@ -51,7 +51,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5151
5252 // Check if this is a background job.
5353 var meta tools.BashResponseMetadata
54− if opts.Result != nil {
54+ if opts.HasResult() {
5555 _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
5656 }
5757
@@ -69,7 +69,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
6969 toolParams = append(toolParams, "background", "true")
7070 }
7171
72− header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...)
72+ header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
7373 if opts.Compact {
7474 return header
7575 }
@@ -78,7 +78,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
7878 return joinToolParts(header, earlyState)
7979 }
8080
81− if opts.Result == nil {
81+ if !opts.HasResult() {
8282 return header
8383 }
8484
@@ -122,7 +122,7 @@ type JobOutputToolRenderContext struct{}
122122// RenderTool implements the [ToolRenderer] interface.
123123func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
124124 cappedWidth := cappedMessageWidth(width)
125− if !opts.ToolCall.Finished && !opts.Canceled {
125+ if opts.IsPending() {
126126 return pendingTool(sty, "Job", opts.Anim)
127127 }
128128
@@ -132,7 +132,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o
132132 }
133133
134134 var description string
135− if opts.Result != nil && opts.Result.Metadata != "" {
135+ if opts.HasResult() && opts.Result.Metadata != "" {
136136 var meta tools.JobOutputResponseMetadata
137137 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
138138 description = cmp.Or(meta.Description, meta.Command)
@@ -140,7 +140,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o
140140 }
141141
142142 content := ""
143− if opts.Result != nil {
143+ if opts.HasResult() {
144144 content = opts.Result.Content
145145 }
146146 return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
@@ -173,7 +173,7 @@ type JobKillToolRenderContext struct{}
173173// RenderTool implements the [ToolRenderer] interface.
174174func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175175 cappedWidth := cappedMessageWidth(width)
176− if !opts.ToolCall.Finished && !opts.Canceled {
176+ if opts.IsPending() {
177177 return pendingTool(sty, "Job", opts.Anim)
178178 }
179179
@@ -183,7 +183,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
183183 }
184184
185185 var description string
186− if opts.Result != nil && opts.Result.Metadata != "" {
186+ if opts.HasResult() && opts.Result.Metadata != "" {
187187 var meta tools.JobKillResponseMetadata
188188 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
189189 description = cmp.Or(meta.Description, meta.Command)
@@ -191,7 +191,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
191191 }
192192
193193 content := ""
194− if opts.Result != nil {
194+ if opts.HasResult() {
195195 content = opts.Result.Content
196196 }
197197 return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
@@ -200,7 +200,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
200200// renderJobTool renders a job-related tool with the common pattern:
201201// header → nested check → early state → body.
202202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203− header := jobHeader(sty, opts.Status(), action, shellID, description, width)
203+ header := jobHeader(sty, opts.Status, action, shellID, description, width)
204204 if opts.Compact {
205205 return header
206206 }
internal/ui/chat/diagnostics.go+3−3
@@ -36,7 +36,7 @@ type DiagnosticsToolRenderContext struct{}
3636// RenderTool implements the [ToolRenderer] interface.
3737func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
3838 cappedWidth := cappedMessageWidth(width)
39− if !opts.ToolCall.Finished && !opts.Canceled {
39+ if opts.IsPending() {
4040 return pendingTool(sty, "Diagnostics", opts.Anim)
4141 }
4242
@@ -49,7 +49,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
4949 mainParam = fsext.PrettyPath(params.FilePath)
5050 }
5151
52− header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam)
52+ header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam)
5353 if opts.Compact {
5454 return header
5555 }
@@ -58,7 +58,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
5858 return joinToolParts(header, earlyState)
5959 }
6060
61− if opts.Result == nil || opts.Result.Content == "" {
61+ if opts.HasEmptyResult() {
6262 return header
6363 }
6464
internal/ui/chat/fetch.go+9−9
@@ -35,7 +35,7 @@ type FetchToolRenderContext struct{}
3535// RenderTool implements the [ToolRenderer] interface.
3636func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
3737 cappedWidth := cappedMessageWidth(width)
38− if !opts.ToolCall.Finished && !opts.Canceled {
38+ if opts.IsPending() {
3939 return pendingTool(sty, "Fetch", opts.Anim)
4040 }
4141
@@ -52,7 +52,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
5252 toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
5353 }
5454
55− header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
55+ header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
5656 if opts.Compact {
5757 return header
5858 }
@@ -61,7 +61,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
6161 return joinToolParts(header, earlyState)
6262 }
6363
64− if opts.Result == nil || opts.Result.Content == "" {
64+ if opts.HasEmptyResult() {
6565 return header
6666 }
6767
@@ -110,7 +110,7 @@ type WebFetchToolRenderContext struct{}
110110// RenderTool implements the [ToolRenderer] interface.
111111func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
112112 cappedWidth := cappedMessageWidth(width)
113− if !opts.ToolCall.Finished && !opts.Canceled {
113+ if opts.IsPending() {
114114 return pendingTool(sty, "Fetch", opts.Anim)
115115 }
116116
@@ -120,7 +120,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op
120120 }
121121
122122 toolParams := []string{params.URL}
123− header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
123+ header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
124124 if opts.Compact {
125125 return header
126126 }
@@ -129,7 +129,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op
129129 return joinToolParts(header, earlyState)
130130 }
131131
132− if opts.Result == nil || opts.Result.Content == "" {
132+ if opts.HasEmptyResult() {
133133 return header
134134 }
135135
@@ -164,7 +164,7 @@ type WebSearchToolRenderContext struct{}
164164// RenderTool implements the [ToolRenderer] interface.
165165func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
166166 cappedWidth := cappedMessageWidth(width)
167− if !opts.ToolCall.Finished && !opts.Canceled {
167+ if opts.IsPending() {
168168 return pendingTool(sty, "Search", opts.Anim)
169169 }
170170
@@ -174,7 +174,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o
174174 }
175175
176176 toolParams := []string{params.Query}
177− header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...)
177+ header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...)
178178 if opts.Compact {
179179 return header
180180 }
@@ -183,7 +183,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o
183183 return joinToolParts(header, earlyState)
184184 }
185185
186− if opts.Result == nil || opts.Result.Content == "" {
186+ if opts.HasEmptyResult() {
187187 return header
188188 }
189189
internal/ui/chat/file.go+14−14
@@ -38,7 +38,7 @@ type ViewToolRenderContext struct{}
3838// RenderTool implements the [ToolRenderer] interface.
3939func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
4040 cappedWidth := cappedMessageWidth(width)
41− if !opts.ToolCall.Finished && !opts.Canceled {
41+ if opts.IsPending() {
4242 return pendingTool(sty, "View", opts.Anim)
4343 }
4444
@@ -56,7 +56,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5656 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
5757 }
5858
59− header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...)
59+ header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...)
6060 if opts.Compact {
6161 return header
6262 }
@@ -65,7 +65,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
6565 return joinToolParts(header, earlyState)
6666 }
6767
68− if opts.Result == nil {
68+ if !opts.HasResult() {
6969 return header
7070 }
7171
@@ -118,7 +118,7 @@ type WriteToolRenderContext struct{}
118118// RenderTool implements the [ToolRenderer] interface.
119119func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
120120 cappedWidth := cappedMessageWidth(width)
121− if !opts.ToolCall.Finished && !opts.Canceled {
121+ if opts.IsPending() {
122122 return pendingTool(sty, "Write", opts.Anim)
123123 }
124124
@@ -128,7 +128,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
128128 }
129129
130130 file := fsext.PrettyPath(params.FilePath)
131− header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file)
131+ header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file)
132132 if opts.Compact {
133133 return header
134134 }
@@ -173,7 +173,7 @@ type EditToolRenderContext struct{}
173173// RenderTool implements the [ToolRenderer] interface.
174174func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175175 // Edit tool uses full width for diffs.
176− if !opts.ToolCall.Finished && !opts.Canceled {
176+ if opts.IsPending() {
177177 return pendingTool(sty, "Edit", opts.Anim)
178178 }
179179
@@ -183,7 +183,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
183183 }
184184
185185 file := fsext.PrettyPath(params.FilePath)
186− header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file)
186+ header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file)
187187 if opts.Compact {
188188 return header
189189 }
@@ -192,7 +192,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
192192 return joinToolParts(header, earlyState)
193193 }
194194
195− if opts.Result == nil {
195+ if !opts.HasResult() {
196196 return header
197197 }
198198
@@ -236,7 +236,7 @@ type MultiEditToolRenderContext struct{}
236236// RenderTool implements the [ToolRenderer] interface.
237237func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
238238 // MultiEdit tool uses full width for diffs.
239− if !opts.ToolCall.Finished && !opts.Canceled {
239+ if opts.IsPending() {
240240 return pendingTool(sty, "Multi-Edit", opts.Anim)
241241 }
242242
@@ -251,7 +251,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
251251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252252 }
253253
254− header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
254+ header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...)
255255 if opts.Compact {
256256 return header
257257 }
@@ -260,7 +260,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
260260 return joinToolParts(header, earlyState)
261261 }
262262
263− if opts.Result == nil {
263+ if !opts.HasResult() {
264264 return header
265265 }
266266
@@ -304,7 +304,7 @@ type DownloadToolRenderContext struct{}
304304// RenderTool implements the [ToolRenderer] interface.
305305func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
306306 cappedWidth := cappedMessageWidth(width)
307− if !opts.ToolCall.Finished && !opts.Canceled {
307+ if opts.IsPending() {
308308 return pendingTool(sty, "Download", opts.Anim)
309309 }
310310
@@ -321,7 +321,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
321321 toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
322322 }
323323
324− header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...)
324+ header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...)
325325 if opts.Compact {
326326 return header
327327 }
@@ -330,7 +330,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
330330 return joinToolParts(header, earlyState)
331331 }
332332
333− if opts.Result == nil || opts.Result.Content == "" {
333+ if opts.HasEmptyResult() {
334334 return header
335335 }
336336
internal/ui/chat/search.go+12−12
@@ -36,7 +36,7 @@ type GlobToolRenderContext struct{}
3636// RenderTool implements the [ToolRenderer] interface.
3737func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
3838 cappedWidth := cappedMessageWidth(width)
39− if !opts.ToolCall.Finished && !opts.Canceled {
39+ if opts.IsPending() {
4040 return pendingTool(sty, "Glob", opts.Anim)
4141 }
4242
@@ -50,7 +50,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5050 toolParams = append(toolParams, "path", params.Path)
5151 }
5252
53− header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...)
53+ header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...)
5454 if opts.Compact {
5555 return header
5656 }
@@ -59,7 +59,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5959 return joinToolParts(header, earlyState)
6060 }
6161
62− if opts.Result == nil || opts.Result.Content == "" {
62+ if !opts.HasResult() || opts.Result.Content == "" {
6363 return header
6464 }
6565
@@ -95,7 +95,7 @@ type GrepToolRenderContext struct{}
9595// RenderTool implements the [ToolRenderer] interface.
9696func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
9797 cappedWidth := cappedMessageWidth(width)
98− if !opts.ToolCall.Finished && !opts.Canceled {
98+ if opts.IsPending() {
9999 return pendingTool(sty, "Grep", opts.Anim)
100100 }
101101
@@ -115,7 +115,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
115115 toolParams = append(toolParams, "literal", "true")
116116 }
117117
118− header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...)
118+ header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...)
119119 if opts.Compact {
120120 return header
121121 }
@@ -124,7 +124,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
124124 return joinToolParts(header, earlyState)
125125 }
126126
127− if opts.Result == nil || opts.Result.Content == "" {
127+ if opts.HasEmptyResult() {
128128 return header
129129 }
130130
@@ -160,7 +160,7 @@ type LSToolRenderContext struct{}
160160// RenderTool implements the [ToolRenderer] interface.
161161func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
162162 cappedWidth := cappedMessageWidth(width)
163− if !opts.ToolCall.Finished && !opts.Canceled {
163+ if opts.IsPending() {
164164 return pendingTool(sty, "List", opts.Anim)
165165 }
166166
@@ -175,7 +175,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
175175 }
176176 path = fsext.PrettyPath(path)
177177
178− header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
178+ header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path)
179179 if opts.Compact {
180180 return header
181181 }
@@ -184,7 +184,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
184184 return joinToolParts(header, earlyState)
185185 }
186186
187− if opts.Result == nil || opts.Result.Content == "" {
187+ if opts.HasEmptyResult() {
188188 return header
189189 }
190190
@@ -220,7 +220,7 @@ type SourcegraphToolRenderContext struct{}
220220// RenderTool implements the [ToolRenderer] interface.
221221func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
222222 cappedWidth := cappedMessageWidth(width)
223− if !opts.ToolCall.Finished && !opts.Canceled {
223+ if opts.IsPending() {
224224 return pendingTool(sty, "Sourcegraph", opts.Anim)
225225 }
226226
@@ -237,7 +237,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
237237 toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
238238 }
239239
240− header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
240+ header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
241241 if opts.Compact {
242242 return header
243243 }
@@ -246,7 +246,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
246246 return joinToolParts(header, earlyState)
247247 }
248248
249− if opts.Result == nil || opts.Result.Content == "" {
249+ if opts.HasEmptyResult() {
250250 return header
251251 }
252252
internal/ui/chat/todos.go+3−3
@@ -40,7 +40,7 @@ type TodosToolRenderContext struct{}
4040// RenderTool implements the [ToolRenderer] interface.
4141func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
4242 cappedWidth := cappedMessageWidth(width)
43− if !opts.ToolCall.Finished && !opts.Canceled {
43+ if opts.IsPending() {
4444 return pendingTool(sty, "To-Do", opts.Anim)
4545 }
4646
@@ -74,7 +74,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
7474 }
7575
7676 // If we have metadata, use it for richer display.
77− if opts.Result != nil && opts.Result.Metadata != "" {
77+ if opts.HasResult() && opts.Result.Metadata != "" {
7878 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
7979 if meta.IsNew {
8080 if meta.JustStarted != "" {
@@ -119,7 +119,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
119119 }
120120
121121 toolParams := []string{headerText}
122− header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...)
122+ header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
123123 if opts.Compact {
124124 return header
125125 }
internal/ui/chat/tools.go+79−58
@@ -42,6 +42,8 @@ type ToolMessageItem interface {
4242 SetResult(res *message.ToolResult)
4343 MessageID() string
4444 SetMessageID(id string)
45+ SetStatus(status ToolStatus)
46+ Status() ToolStatus
4547}
4648
4749// Compactable is an interface for tool items that can render in a compacted mode.
@@ -54,7 +56,17 @@ type Compactable interface {
5456type SpinningState struct {
5557 ToolCall message.ToolCall
5658 Result *message.ToolResult
57− Canceled bool
59+ Status ToolStatus
60+}
61+
62+// IsCanceled returns true if the tool status is canceled.
63+func (s *SpinningState) IsCanceled() bool {
64+ return s.Status == ToolStatusCanceled
65+}
66+
67+// HasResult returns true if the result is not nil.
68+func (s *SpinningState) HasResult() bool {
69+ return s.Result != nil
5870}
5971
6072// SpinningFunc is a function type for custom spinning logic.
@@ -71,32 +83,34 @@ func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
7183
7284// ToolRenderOpts contains the data needed to render a tool call.
7385type ToolRenderOpts struct {
74− ToolCall message.ToolCall
75− Result *message.ToolResult
76− Canceled bool
77− Anim *anim.Anim
78− ExpandedContent bool
79− Compact bool
80− IsSpinning bool
81− PermissionRequested bool
82− PermissionGranted bool
83−}
84−
85−// Status returns the current status of the tool call.
86−func (opts *ToolRenderOpts) Status() ToolStatus {
87− if opts.Canceled && opts.Result == nil {
88− return ToolStatusCanceled
89− }
90− if opts.Result != nil {
91− if opts.Result.IsError {
92− return ToolStatusError
93− }
94− return ToolStatusSuccess
95− }
96− if opts.PermissionRequested && !opts.PermissionGranted {
97− return ToolStatusAwaitingPermission
98− }
99− return ToolStatusRunning
86+ ToolCall message.ToolCall
87+ Result *message.ToolResult
88+ Anim *anim.Anim
89+ ExpandedContent bool
90+ Compact bool
91+ IsSpinning bool
92+ Status ToolStatus
93+}
94+
95+// IsPending returns true if the tool call is still pending (not finished and
96+// not canceled).
97+func (o *ToolRenderOpts) IsPending() bool {
98+ return !o.ToolCall.Finished && !o.IsCanceled()
99+}
100+
101+// IsCanceled returns true if the tool status is canceled.
102+func (o *ToolRenderOpts) IsCanceled() bool {
103+ return o.Status == ToolStatusCanceled
104+}
105+
106+// HasResult returns true if the result is not nil.
107+func (o *ToolRenderOpts) HasResult() bool {
108+ return o.Result != nil
109+}
110+
111+// HasEmptyResult returns true if the result is nil or has empty content.
112+func (o *ToolRenderOpts) HasEmptyResult() bool {
113+ return o.Result == nil || o.Result.Content == ""
100114}
101115
102116// ToolRenderer represents an interface for rendering tool calls.
@@ -118,13 +132,11 @@ type baseToolMessageItem struct {
118132 *cachedMessageItem
119133 *focusableMessageItem
120134
121− toolRenderer ToolRenderer
122− toolCall message.ToolCall
123− result *message.ToolResult
124− messageID string
125− canceled bool
126− permissionRequested bool
127− permissionGranted bool
135+ toolRenderer ToolRenderer
136+ toolCall message.ToolCall
137+ result *message.ToolResult
138+ messageID string
139+ status ToolStatus
128140 // we use this so we can efficiently cache
129141 // tools that have a capped width (e.x bash.. and others)
130142 hasCappedWidth bool
@@ -150,6 +162,11 @@ func newBaseToolMessageItem(
150162 // we only do full width for diffs (as far as I know)
151163 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
152164
165+ status := ToolStatusRunning
166+ if canceled {
167+ status = ToolStatusCanceled
168+ }
169+
153170 t := &baseToolMessageItem{
154171 highlightableMessageItem: defaultHighlighter(sty),
155172 cachedMessageItem: &cachedMessageItem{},
@@ -158,7 +175,7 @@ func newBaseToolMessageItem(
158175 toolRenderer: toolRenderer,
159176 toolCall: toolCall,
160177 result: result,
161− canceled: canceled,
178+ status: status,
162179 hasCappedWidth: hasCappedWidth,
163180 }
164181 t.anim = anim.New(anim.Settings{
@@ -285,15 +302,13 @@ func (t *baseToolMessageItem) Render(width int) string {
285302 // if we are spinning or there is no cache rerender
286303 if !ok || t.isSpinning() {
287304 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
288− ToolCall: t.toolCall,
289− Result: t.result,
290− Canceled: t.canceled,
291− Anim: t.anim,
292− ExpandedContent: t.expandedContent,
293− Compact: t.isCompact,
294− PermissionRequested: t.permissionRequested,
295− PermissionGranted: t.permissionGranted,
296− IsSpinning: t.isSpinning(),
305+ ToolCall: t.toolCall,
306+ Result: t.result,
307+ Anim: t.anim,
308+ ExpandedContent: t.expandedContent,
309+ Compact: t.isCompact,
310+ IsSpinning: t.isSpinning(),
311+ Status: t.computeStatus(),
297312 })
298313 height = lipgloss.Height(content)
299314 // cache the rendered content
@@ -331,20 +346,26 @@ func (t *baseToolMessageItem) SetMessageID(id string) {
331346 t.messageID = id
332347}
333348
334−// SetPermissionRequested sets whether permission has been requested for this tool call.
335−// TODO: Consider merging with SetPermissionGranted and add an interface for
336−// permission management.
337−func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
338− t.permissionRequested = requested
349+// SetStatus sets the tool status.
350+func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
351+ t.status = status
339352 t.clearCache()
340353}
341354
342−// SetPermissionGranted sets whether permission has been granted for this tool call.
343−// TODO: Consider merging with SetPermissionRequested and add an interface for
344−// permission management.
345−func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
346− t.permissionGranted = granted
347− t.clearCache()
355+// Status returns the current tool status.
356+func (t *baseToolMessageItem) Status() ToolStatus {
357+ return t.status
358+}
359+
360+// computeStatus computes the effective status considering the result.
361+func (t *baseToolMessageItem) computeStatus() ToolStatus {
362+ if t.result != nil {
363+ if t.result.IsError {
364+ return ToolStatusError
365+ }
366+ return ToolStatusSuccess
367+ }
368+ return t.status
348369}
349370
350371// isSpinning returns true if the tool should show animation.
@@ -353,10 +374,10 @@ func (t *baseToolMessageItem) isSpinning() bool {
353374 return t.spinningFunc(SpinningState{
354375 ToolCall: t.toolCall,
355376 Result: t.result,
356− Canceled: t.canceled,
377+ Status: t.status,
357378 })
358379 }
359− return !t.toolCall.Finished && !t.canceled
380+ return !t.toolCall.Finished && t.status != ToolStatusCanceled
360381}
361382
362383// 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 {
396417// Returns the rendered output and true if early state was handled.
397418func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
398419 var msg string
399− switch opts.Status() {
420+ switch opts.Status {
400421 case ToolStatusError:
401422 msg = toolErrorContent(sty, opts.Result, width)
402423 case ToolStatusCanceled:
internal/ui/common/scrollbar.gonew file+46
@@ -0,0 +1,46 @@
1+package common
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/crush/internal/ui/styles"
7+)
8+
9+// Scrollbar renders a vertical scrollbar based on content and viewport size.
10+// Returns an empty string if content fits within viewport (no scrolling needed).
11+func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string {
12+ if height <= 0 || contentSize <= viewportSize {
13+ return ""
14+ }
15+
16+ // Calculate thumb size (minimum 1 character).
17+ thumbSize := max(1, height*viewportSize/contentSize)
18+
19+ // Calculate thumb position.
20+ maxOffset := contentSize - viewportSize
21+ if maxOffset <= 0 {
22+ return ""
23+ }
24+
25+ // Calculate where the thumb starts.
26+ trackSpace := height - thumbSize
27+ thumbPos := 0
28+ if trackSpace > 0 && maxOffset > 0 {
29+ thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
30+ }
31+
32+ // Build the scrollbar.
33+ var sb strings.Builder
34+ for i := range height {
35+ if i > 0 {
36+ sb.WriteString("\n")
37+ }
38+ if i >= thumbPos && i < thumbPos+thumbSize {
39+ sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
40+ } else {
41+ sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
42+ }
43+ }
44+
45+ return sb.String()
46+}
internal/ui/dialog/actions.go+5
@@ -3,6 +3,7 @@ package dialog
33import (
44 tea "charm.land/bubbletea/v2"
55 "github.com/charmbracelet/crush/internal/config"
6+ "github.com/charmbracelet/crush/internal/permission"
67 "github.com/charmbracelet/crush/internal/session"
78)
89
@@ -39,6 +40,10 @@ type (
3940 ActionSummarize struct {
4041 SessionID string
4142 }
43+ ActionPermissionResponse struct {
44+ Permission permission.PermissionRequest
45+ Action PermissionAction
46+ }
4247)
4348
4449// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
internal/ui/dialog/commands.go+8−9
@@ -220,16 +220,15 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
220220// Draw implements [Dialog].
221221func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
222222 t := c.com.Styles
223− width := max(0, min(100, area.Dx()))
224− height := max(0, min(30, area.Dy()))
223+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
224+ height := max(0, min(defaultDialogHeight, area.Dy()))
225225 c.width = width
226− // TODO: Why do we need this 2?
227− innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
228− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
229− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
226+ innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
227+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
228+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
230229 t.Dialog.HelpView.GetVerticalFrameSize() +
231− // TODO: Why do we need this 2?
232− t.Dialog.View.GetVerticalFrameSize() + 2
230+ t.Dialog.View.GetVerticalFrameSize()
231+
233232 c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
234233 c.list.SetSize(innerWidth, height-heightOffset)
235234 c.help.SetWidth(innerWidth)
@@ -416,7 +415,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
416415 cfg := c.com.Config()
417416 agentCfg := cfg.Agents[config.AgentCoder]
418417 model := cfg.GetModelByType(agentCfg.Model)
419− if model.SupportsImages {
418+ if model != nil && model.SupportsImages {
420419 commands = append(commands, uicmd.Command{
421420 ID: "file_picker",
422421 Title: "Open File Picker",
internal/ui/dialog/dialog.go+12
@@ -8,6 +8,18 @@ import (
88 uv "github.com/charmbracelet/ultraviolet"
99)
1010
11+// Dialog sizing constants.
12+const (
13+ // defaultDialogMaxWidth is the maximum width for standard dialogs.
14+ defaultDialogMaxWidth = 120
15+ // defaultDialogHeight is the default height for standard dialogs.
16+ defaultDialogHeight = 30
17+ // titleContentHeight is the height of the title content line.
18+ titleContentHeight = 1
19+ // inputContentHeight is the height of the input content line.
20+ inputContentHeight = 1
21+)
22+
1123// CloseKey is the default key binding to close dialogs.
1224var CloseKey = key.NewBinding(
1325 key.WithKeys("esc", "alt+esc"),
internal/ui/dialog/models.go+6−8
@@ -241,15 +241,13 @@ func (m *Models) modelTypeRadioView() string {
241241// Draw implements [Dialog].
242242func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
243243 t := m.com.Styles
244− width := max(0, min(60, area.Dx()))
245− height := max(0, min(30, area.Dy()))
246− // TODO: Why do we need this 2?
247− innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
248− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
249− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
244+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
245+ height := max(0, min(defaultDialogHeight, area.Dy()))
246+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
247+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
248+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
250249 t.Dialog.HelpView.GetVerticalFrameSize() +
251− // TODO: Why do we need this 2?
252− t.Dialog.View.GetVerticalFrameSize() + 2
250+ t.Dialog.View.GetVerticalFrameSize()
253251 m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
254252 m.list.SetSize(innerWidth, height-heightOffset)
255253 m.help.SetWidth(innerWidth)
internal/ui/dialog/permissions.gonew file+695
@@ -0,0 +1,695 @@
1+package dialog
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/crush/internal/agent/tools"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ uv "github.com/charmbracelet/ultraviolet"
18+)
19+
20+// PermissionsID is the identifier for the permissions dialog.
21+const PermissionsID = "permissions"
22+
23+// PermissionAction represents the user's response to a permission request.
24+type PermissionAction string
25+
26+const (
27+ PermissionAllow PermissionAction = "allow"
28+ PermissionAllowForSession PermissionAction = "allow_session"
29+ PermissionDeny PermissionAction = "deny"
30+)
31+
32+// Permissions dialog sizing constants.
33+const (
34+ // diffMaxWidth is the maximum width for diff views.
35+ diffMaxWidth = 180
36+ // diffSizeRatio is the size ratio for diff views relative to window.
37+ diffSizeRatio = 0.8
38+ // simpleMaxWidth is the maximum width for simple content dialogs.
39+ simpleMaxWidth = 100
40+ // simpleSizeRatio is the size ratio for simple content dialogs.
41+ simpleSizeRatio = 0.6
42+ // simpleHeightRatio is the height ratio for simple content dialogs.
43+ simpleHeightRatio = 0.5
44+ // splitModeMinWidth is the minimum width to enable split diff mode.
45+ splitModeMinWidth = 140
46+ // layoutSpacingLines is the number of empty lines used for layout spacing.
47+ layoutSpacingLines = 4
48+ // minWindowWidth is the minimum window width before forcing fullscreen.
49+ minWindowWidth = 60
50+ // minWindowHeight is the minimum window height before forcing fullscreen.
51+ minWindowHeight = 20
52+)
53+
54+// Permissions represents a dialog for permission requests.
55+type Permissions struct {
56+ com *common.Common
57+ windowWidth int // Terminal window dimensions.
58+ windowHeight int
59+ fullscreen bool // true when dialog is fullscreen
60+
61+ permission permission.PermissionRequest
62+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
63+
64+ viewport viewport.Model
65+ viewportDirty bool // true when viewport content needs to be re-rendered
66+ viewportWidth int
67+
68+ // Diff view state.
69+ diffSplitMode *bool // nil means use default based on width
70+ defaultDiffSplitMode bool // default split mode based on width
71+ unifiedDiffContent string
72+ splitDiffContent string
73+
74+ help help.Model
75+ keyMap permissionsKeyMap
76+}
77+
78+type permissionsKeyMap struct {
79+ Left key.Binding
80+ Right key.Binding
81+ Tab key.Binding
82+ Select key.Binding
83+ Allow key.Binding
84+ AllowSession key.Binding
85+ Deny key.Binding
86+ Close key.Binding
87+ ToggleDiffMode key.Binding
88+ ToggleFullscreen key.Binding
89+ ScrollUp key.Binding
90+ ScrollDown key.Binding
91+ ScrollLeft key.Binding
92+ ScrollRight key.Binding
93+ Choose key.Binding
94+ Scroll key.Binding
95+}
96+
97+func defaultPermissionsKeyMap() permissionsKeyMap {
98+ return permissionsKeyMap{
99+ Left: key.NewBinding(
100+ key.WithKeys("left", "h"),
101+ key.WithHelp("←", "previous"),
102+ ),
103+ Right: key.NewBinding(
104+ key.WithKeys("right", "l"),
105+ key.WithHelp("→", "next"),
106+ ),
107+ Tab: key.NewBinding(
108+ key.WithKeys("tab"),
109+ key.WithHelp("tab", "next option"),
110+ ),
111+ Select: key.NewBinding(
112+ key.WithKeys("enter", "ctrl+y"),
113+ key.WithHelp("enter", "confirm"),
114+ ),
115+ Allow: key.NewBinding(
116+ key.WithKeys("a", "A", "ctrl+a"),
117+ key.WithHelp("a", "allow"),
118+ ),
119+ AllowSession: key.NewBinding(
120+ key.WithKeys("s", "S", "ctrl+s"),
121+ key.WithHelp("s", "allow session"),
122+ ),
123+ Deny: key.NewBinding(
124+ key.WithKeys("d", "D"),
125+ key.WithHelp("d", "deny"),
126+ ),
127+ Close: CloseKey,
128+ ToggleDiffMode: key.NewBinding(
129+ key.WithKeys("t"),
130+ key.WithHelp("t", "toggle diff view"),
131+ ),
132+ ToggleFullscreen: key.NewBinding(
133+ key.WithKeys("f"),
134+ key.WithHelp("f", "toggle fullscreen"),
135+ ),
136+ ScrollUp: key.NewBinding(
137+ key.WithKeys("shift+up", "K"),
138+ key.WithHelp("shift+↑", "scroll up"),
139+ ),
140+ ScrollDown: key.NewBinding(
141+ key.WithKeys("shift+down", "J"),
142+ key.WithHelp("shift+↓", "scroll down"),
143+ ),
144+ ScrollLeft: key.NewBinding(
145+ key.WithKeys("shift+left", "H"),
146+ key.WithHelp("shift+←", "scroll left"),
147+ ),
148+ ScrollRight: key.NewBinding(
149+ key.WithKeys("shift+right", "L"),
150+ key.WithHelp("shift+→", "scroll right"),
151+ ),
152+ Choose: key.NewBinding(
153+ key.WithKeys("left", "right"),
154+ key.WithHelp("←/→", "choose"),
155+ ),
156+ Scroll: key.NewBinding(
157+ key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
158+ key.WithHelp("shift+←↓↑→", "scroll"),
159+ ),
160+ }
161+}
162+
163+var _ Dialog = (*Permissions)(nil)
164+
165+// PermissionsOption configures the permissions dialog.
166+type PermissionsOption func(*Permissions)
167+
168+// WithDiffMode sets the initial diff mode (split or unified).
169+func WithDiffMode(split bool) PermissionsOption {
170+ return func(p *Permissions) {
171+ p.diffSplitMode = &split
172+ }
173+}
174+
175+// NewPermissions creates a new permissions dialog.
176+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
177+ h := help.New()
178+ h.Styles = com.Styles.DialogHelpStyles()
179+
180+ km := defaultPermissionsKeyMap()
181+
182+ // Configure viewport with matching keybindings.
183+ vp := viewport.New()
184+ vp.KeyMap = viewport.KeyMap{
185+ Up: km.ScrollUp,
186+ Down: km.ScrollDown,
187+ Left: km.ScrollLeft,
188+ Right: km.ScrollRight,
189+ // Disable other viewport keys to avoid conflicts with dialog shortcuts.
190+ PageUp: key.NewBinding(key.WithDisabled()),
191+ PageDown: key.NewBinding(key.WithDisabled()),
192+ HalfPageUp: key.NewBinding(key.WithDisabled()),
193+ HalfPageDown: key.NewBinding(key.WithDisabled()),
194+ }
195+
196+ p := &Permissions{
197+ com: com,
198+ permission: perm,
199+ selectedOption: 0,
200+ viewport: vp,
201+ help: h,
202+ keyMap: km,
203+ }
204+
205+ for _, opt := range opts {
206+ opt(p)
207+ }
208+
209+ return p
210+}
211+
212+// Calculate usable content width (dialog border + horizontal padding).
213+func (p *Permissions) calculateContentWidth(width int) int {
214+ t := p.com.Styles
215+ const dialogHorizontalPadding = 2
216+ return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
217+}
218+
219+// ID implements [Dialog].
220+func (*Permissions) ID() string {
221+ return PermissionsID
222+}
223+
224+// HandleMsg implements [Dialog].
225+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
226+ switch msg := msg.(type) {
227+ case tea.KeyPressMsg:
228+ switch {
229+ case key.Matches(msg, p.keyMap.Close):
230+ // Escape denies the permission request.
231+ return p.respond(PermissionDeny)
232+ case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
233+ p.selectedOption = (p.selectedOption + 1) % 3
234+ case key.Matches(msg, p.keyMap.Left):
235+ // Add 2 instead of subtracting 1 to avoid negative modulo.
236+ p.selectedOption = (p.selectedOption + 2) % 3
237+ case key.Matches(msg, p.keyMap.Select):
238+ return p.selectCurrentOption()
239+ case key.Matches(msg, p.keyMap.Allow):
240+ return p.respond(PermissionAllow)
241+ case key.Matches(msg, p.keyMap.AllowSession):
242+ return p.respond(PermissionAllowForSession)
243+ case key.Matches(msg, p.keyMap.Deny):
244+ return p.respond(PermissionDeny)
245+ case key.Matches(msg, p.keyMap.ToggleDiffMode):
246+ if p.hasDiffView() {
247+ newMode := !p.isSplitMode()
248+ p.diffSplitMode = &newMode
249+ p.viewportDirty = true
250+ }
251+ case key.Matches(msg, p.keyMap.ToggleFullscreen):
252+ if p.hasDiffView() {
253+ p.fullscreen = !p.fullscreen
254+ }
255+ case key.Matches(msg, p.keyMap.ScrollDown):
256+ p.viewport, _ = p.viewport.Update(msg)
257+ case key.Matches(msg, p.keyMap.ScrollUp):
258+ p.viewport, _ = p.viewport.Update(msg)
259+ case key.Matches(msg, p.keyMap.ScrollLeft):
260+ p.viewport, _ = p.viewport.Update(msg)
261+ case key.Matches(msg, p.keyMap.ScrollRight):
262+ p.viewport, _ = p.viewport.Update(msg)
263+ }
264+ case tea.MouseWheelMsg:
265+ p.viewport, _ = p.viewport.Update(msg)
266+ default:
267+ // Pass unhandled keys to viewport for non-diff content scrolling.
268+ if !p.hasDiffView() {
269+ p.viewport, _ = p.viewport.Update(msg)
270+ p.viewportDirty = true
271+ }
272+ }
273+
274+ return nil
275+}
276+
277+func (p *Permissions) selectCurrentOption() tea.Msg {
278+ switch p.selectedOption {
279+ case 0:
280+ return p.respond(PermissionAllow)
281+ case 1:
282+ return p.respond(PermissionAllowForSession)
283+ default:
284+ return p.respond(PermissionDeny)
285+ }
286+}
287+
288+func (p *Permissions) respond(action PermissionAction) tea.Msg {
289+ return ActionPermissionResponse{
290+ Permission: p.permission,
291+ Action: action,
292+ }
293+}
294+
295+func (p *Permissions) hasDiffView() bool {
296+ switch p.permission.ToolName {
297+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
298+ return true
299+ }
300+ return false
301+}
302+
303+func (p *Permissions) isSplitMode() bool {
304+ if p.diffSplitMode != nil {
305+ return *p.diffSplitMode
306+ }
307+ return p.defaultDiffSplitMode
308+}
309+
310+// Draw implements [Dialog].
311+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
312+ t := p.com.Styles
313+ // Force fullscreen when window is too small.
314+ forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
315+
316+ // Calculate dialog dimensions based on fullscreen state and content type.
317+ var width, height int
318+ if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
319+ // Use nearly full window for fullscreen.
320+ width = area.Dx()
321+ height = area.Dy()
322+ } else if p.hasDiffView() {
323+ // Wide for side-by-side diffs, capped for readability.
324+ width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
325+ height = int(float64(area.Dy()) * diffSizeRatio)
326+ } else {
327+ // Narrower for simple content like commands/URLs.
328+ width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
329+ height = int(float64(area.Dy()) * simpleHeightRatio)
330+ }
331+
332+ dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
333+
334+ contentWidth := p.calculateContentWidth(width)
335+ header := p.renderHeader(contentWidth)
336+ buttons := p.renderButtons(contentWidth)
337+ helpView := p.help.View(p)
338+
339+ // Calculate available height for content.
340+ headerHeight := lipgloss.Height(header)
341+ buttonsHeight := lipgloss.Height(buttons)
342+ helpHeight := lipgloss.Height(helpView)
343+ frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
344+ availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight
345+
346+ p.defaultDiffSplitMode = width >= splitModeMinWidth
347+
348+ if p.viewport.Width() != contentWidth-1 {
349+ // Mark diff content as dirty if width has changed
350+ p.viewportDirty = true
351+ }
352+
353+ var content string
354+ var scrollbar string
355+ // Non-diff content uses the viewport for scrolling.
356+ p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar
357+ p.viewport.SetHeight(availableHeight)
358+ if p.viewportDirty {
359+ p.viewport.SetContent(p.renderContent(contentWidth - 1))
360+ p.viewportWidth = p.viewport.Width()
361+ p.viewportDirty = false
362+ }
363+ content = p.viewport.View()
364+ if p.canScroll() {
365+ scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
366+ }
367+
368+ // Join content with scrollbar if present.
369+ if scrollbar != "" {
370+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
371+ }
372+
373+ parts := []string{header}
374+ if content != "" {
375+ parts = append(parts, "", content)
376+ }
377+ parts = append(parts, "", buttons, "", helpView)
378+
379+ innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
380+ DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
381+ return nil
382+}
383+
384+func (p *Permissions) renderHeader(contentWidth int) string {
385+ t := p.com.Styles
386+
387+ title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
388+ title = t.Dialog.Title.Render(title)
389+
390+ // Tool info.
391+ toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
392+ pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
393+
394+ lines := []string{title, "", toolLine, pathLine}
395+
396+ // Add tool-specific header info.
397+ switch p.permission.ToolName {
398+ case tools.BashToolName:
399+ if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
400+ lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
401+ }
402+ case tools.DownloadToolName:
403+ if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
404+ lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
405+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
406+ }
407+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
408+ var filePath string
409+ switch params := p.permission.Params.(type) {
410+ case tools.EditPermissionsParams:
411+ filePath = params.FilePath
412+ case tools.WritePermissionsParams:
413+ filePath = params.FilePath
414+ case tools.MultiEditPermissionsParams:
415+ filePath = params.FilePath
416+ case tools.ViewPermissionsParams:
417+ filePath = params.FilePath
418+ }
419+ if filePath != "" {
420+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
421+ }
422+ case tools.LSToolName:
423+ if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
424+ lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
425+ }
426+ }
427+
428+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
429+}
430+
431+func (p *Permissions) renderKeyValue(key, value string, width int) string {
432+ t := p.com.Styles
433+ keyStyle := t.Muted
434+ valueStyle := t.Base
435+
436+ keyStr := keyStyle.Render(key)
437+ valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
438+
439+ return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
440+}
441+
442+func (p *Permissions) renderContent(width int) string {
443+ switch p.permission.ToolName {
444+ case tools.BashToolName:
445+ return p.renderBashContent()
446+ case tools.EditToolName:
447+ return p.renderEditContent(width)
448+ case tools.WriteToolName:
449+ return p.renderWriteContent(width)
450+ case tools.MultiEditToolName:
451+ return p.renderMultiEditContent(width)
452+ case tools.DownloadToolName:
453+ return p.renderDownloadContent()
454+ case tools.FetchToolName:
455+ return p.renderFetchContent()
456+ case tools.AgenticFetchToolName:
457+ return p.renderAgenticFetchContent()
458+ case tools.ViewToolName:
459+ return p.renderViewContent()
460+ case tools.LSToolName:
461+ return p.renderLSContent()
462+ default:
463+ return p.renderDefaultContent()
464+ }
465+}
466+
467+func (p *Permissions) renderBashContent() string {
468+ params, ok := p.permission.Params.(tools.BashPermissionsParams)
469+ if !ok {
470+ return ""
471+ }
472+
473+ return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
474+}
475+
476+func (p *Permissions) renderEditContent(contentWidth int) string {
477+ params, ok := p.permission.Params.(tools.EditPermissionsParams)
478+ if !ok {
479+ return ""
480+ }
481+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
482+}
483+
484+func (p *Permissions) renderWriteContent(contentWidth int) string {
485+ params, ok := p.permission.Params.(tools.WritePermissionsParams)
486+ if !ok {
487+ return ""
488+ }
489+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
490+}
491+
492+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
493+ params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
494+ if !ok {
495+ return ""
496+ }
497+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
498+}
499+
500+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
501+ if !p.viewportDirty {
502+ if p.isSplitMode() {
503+ return p.splitDiffContent
504+ }
505+ return p.unifiedDiffContent
506+ }
507+
508+ isSplitMode := p.isSplitMode()
509+ formatter := common.DiffFormatter(p.com.Styles).
510+ Before(fsext.PrettyPath(filePath), oldContent).
511+ After(fsext.PrettyPath(filePath), newContent).
512+ // TODO: Allow horizontal scrolling instead of cropping. However, the
513+ // diffview currently would only background color the width of the
514+ // content. If the viewport is wider than the content, the rest of the
515+ // line would not be colored properly.
516+ Width(contentWidth)
517+
518+ var result string
519+ if isSplitMode {
520+ formatter = formatter.Split()
521+ p.splitDiffContent = formatter.String()
522+ result = p.splitDiffContent
523+ } else {
524+ formatter = formatter.Unified()
525+ p.unifiedDiffContent = formatter.String()
526+ result = p.unifiedDiffContent
527+ }
528+
529+ return result
530+}
531+
532+func (p *Permissions) renderDownloadContent() string {
533+ params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
534+ if !ok {
535+ return ""
536+ }
537+
538+ content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
539+ if params.Timeout > 0 {
540+ content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
541+ }
542+
543+ return p.com.Styles.Dialog.ContentPanel.Render(content)
544+}
545+
546+func (p *Permissions) renderFetchContent() string {
547+ params, ok := p.permission.Params.(tools.FetchPermissionsParams)
548+ if !ok {
549+ return ""
550+ }
551+
552+ return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
553+}
554+
555+func (p *Permissions) renderAgenticFetchContent() string {
556+ params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
557+ if !ok {
558+ return ""
559+ }
560+
561+ var content string
562+ if params.URL != "" {
563+ content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
564+ } else {
565+ content = fmt.Sprintf("Prompt: %s", params.Prompt)
566+ }
567+
568+ return p.com.Styles.Dialog.ContentPanel.Render(content)
569+}
570+
571+func (p *Permissions) renderViewContent() string {
572+ params, ok := p.permission.Params.(tools.ViewPermissionsParams)
573+ if !ok {
574+ return ""
575+ }
576+
577+ content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
578+ if params.Offset > 0 {
579+ content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
580+ }
581+ if params.Limit > 0 && params.Limit != 2000 {
582+ content += fmt.Sprintf("\nLines to read: %d", params.Limit)
583+ }
584+
585+ return p.com.Styles.Dialog.ContentPanel.Render(content)
586+}
587+
588+func (p *Permissions) renderLSContent() string {
589+ params, ok := p.permission.Params.(tools.LSPermissionsParams)
590+ if !ok {
591+ return ""
592+ }
593+
594+ content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
595+ if len(params.Ignore) > 0 {
596+ content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
597+ }
598+
599+ return p.com.Styles.Dialog.ContentPanel.Render(content)
600+}
601+
602+func (p *Permissions) renderDefaultContent() string {
603+ content := p.permission.Description
604+
605+ // Pretty-print JSON params if available.
606+ if p.permission.Params != nil {
607+ var paramStr string
608+ if str, ok := p.permission.Params.(string); ok {
609+ paramStr = str
610+ } else {
611+ paramStr = fmt.Sprintf("%v", p.permission.Params)
612+ }
613+
614+ var parsed any
615+ if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
616+ if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
617+ if content != "" {
618+ content += "\n\n"
619+ }
620+ content += string(b)
621+ }
622+ } else if paramStr != "" {
623+ if content != "" {
624+ content += "\n\n"
625+ }
626+ content += paramStr
627+ }
628+ }
629+
630+ if content == "" {
631+ return ""
632+ }
633+
634+ return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
635+}
636+
637+func (p *Permissions) renderButtons(contentWidth int) string {
638+ buttons := []common.ButtonOpts{
639+ {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
640+ {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
641+ {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
642+ }
643+
644+ content := common.ButtonGroup(p.com.Styles, buttons, " ")
645+
646+ // If buttons are too wide, stack them vertically.
647+ if lipgloss.Width(content) > contentWidth {
648+ content = common.ButtonGroup(p.com.Styles, buttons, "\n")
649+ return lipgloss.NewStyle().
650+ Width(contentWidth).
651+ Align(lipgloss.Center).
652+ Render(content)
653+ }
654+
655+ return lipgloss.NewStyle().
656+ Width(contentWidth).
657+ Align(lipgloss.Right).
658+ Render(content)
659+}
660+
661+func (p *Permissions) canScroll() bool {
662+ if p.hasDiffView() {
663+ // Diff views can always scroll.
664+ return true
665+ }
666+ // For non-diff content, check if viewport has scrollable content.
667+ return !p.viewport.AtTop() || !p.viewport.AtBottom()
668+}
669+
670+// ShortHelp implements [help.KeyMap].
671+func (p *Permissions) ShortHelp() []key.Binding {
672+ bindings := []key.Binding{
673+ p.keyMap.Choose,
674+ p.keyMap.Select,
675+ p.keyMap.Close,
676+ }
677+
678+ if p.canScroll() {
679+ bindings = append(bindings, p.keyMap.Scroll)
680+ }
681+
682+ if p.hasDiffView() {
683+ bindings = append(bindings,
684+ p.keyMap.ToggleDiffMode,
685+ p.keyMap.ToggleFullscreen,
686+ )
687+ }
688+
689+ return bindings
690+}
691+
692+// FullHelp implements [help.KeyMap].
693+func (p *Permissions) FullHelp() [][]key.Binding {
694+ return [][]key.Binding{p.ShortHelp()}
695+}
internal/ui/dialog/sessions.go+5−7
@@ -143,15 +143,13 @@ func (s *Session) Cursor() *tea.Cursor {
143143// Draw implements [Dialog].
144144func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
145145 t := s.com.Styles
146− width := max(0, min(120, area.Dx()))
147− height := max(0, min(30, area.Dy()))
148− // TODO: Why do we need this 2?
146+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
147+ height := max(0, min(defaultDialogHeight, area.Dy()))
149148 innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
150− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
151− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
149+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
150+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
152151 t.Dialog.HelpView.GetVerticalFrameSize() +
153− // TODO: Why do we need this 2?
154− t.Dialog.View.GetVerticalFrameSize() + 2
152+ t.Dialog.View.GetVerticalFrameSize()
155153 s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
156154 s.list.SetSize(innerWidth, height-heightOffset)
157155 s.help.SetWidth(innerWidth)
internal/ui/model/ui.go+136
@@ -13,6 +13,7 @@ import (
1313 "slices"
1414 "strconv"
1515 "strings"
16+ "time"
1617
1718 "charm.land/bubbles/v2/help"
1819 "charm.land/bubbles/v2/key"
@@ -74,6 +75,8 @@ type openEditorMsg struct {
7475 Text string
7576}
7677
78+type cancelTimerExpiredMsg struct{}
79+
7780// UI represents the main user interface model.
7881type UI struct {
7982 com *common.Common
@@ -94,6 +97,9 @@ type UI struct {
9497 dialog *dialog.Overlay
9598 status *Status
9699
100+ // isCanceling tracks whether the user has pressed escape once to cancel.
101+ isCanceling bool
102+
97103 // header is the last cached header logo
98104 header string
99105
@@ -280,6 +286,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
280286 }
281287 }
282288 }
289+ case pubsub.Event[permission.PermissionRequest]:
290+ if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
291+ cmds = append(cmds, cmd)
292+ }
293+ case pubsub.Event[permission.PermissionNotification]:
294+ m.handlePermissionNotification(msg.Payload)
295+ case cancelTimerExpiredMsg:
296+ m.isCanceling = false
283297 case tea.TerminalVersionMsg:
284298 termVersion := strings.ToLower(msg.Name)
285299 // Only enable progress bar for the following terminals.
@@ -348,6 +362,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
348362 m.chat.HandleMouseUp(x, y)
349363 }
350364 case tea.MouseWheelMsg:
365+ // Pass mouse events to dialogs first if any are open.
366+ if m.dialog.HasDialogs() {
367+ m.dialog.Update(msg)
368+ return m, tea.Batch(cmds...)
369+ }
370+
371+ // Otherwise handle mouse wheel for chat.
351372 switch m.state {
352373 case uiChat:
353374 switch msg.Button {
@@ -778,6 +799,17 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
778799 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
779800 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
780801 m.dialog.CloseDialog(dialog.ModelsID)
802+ // TODO CHANGE
803+ case dialog.ActionPermissionResponse:
804+ m.dialog.CloseDialog(dialog.PermissionsID)
805+ switch msg.Action {
806+ case dialog.PermissionAllow:
807+ m.com.App.Permissions.Grant(msg.Permission)
808+ case dialog.PermissionAllowForSession:
809+ m.com.App.Permissions.GrantPersistent(msg.Permission)
810+ case dialog.PermissionDeny:
811+ m.com.App.Permissions.Deny(msg.Permission)
812+ }
781813 }
782814
783815 return tea.Batch(cmds...)
@@ -825,6 +857,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
825857 return m.handleDialogMsg(msg)
826858 }
827859
860+ // Handle cancel key when agent is busy.
861+ if key.Matches(msg, m.keyMap.Chat.Cancel) {
862+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
863+ if cmd := m.cancelAgent(); cmd != nil {
864+ cmds = append(cmds, cmd)
865+ }
866+ return tea.Batch(cmds...)
867+ }
868+ }
869+
828870 switch m.state {
829871 case uiConfigure:
830872 return tea.Batch(cmds...)
@@ -1207,6 +1249,17 @@ func (m *UI) ShortHelp() []key.Binding {
12071249 case uiInitialize:
12081250 binds = append(binds, k.Quit)
12091251 case uiChat:
1252+ // Show cancel binding if agent is busy.
1253+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1254+ cancelBinding := k.Chat.Cancel
1255+ if m.isCanceling {
1256+ cancelBinding.SetHelp("esc", "press again to cancel")
1257+ } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1258+ cancelBinding.SetHelp("esc", "clear queue")
1259+ }
1260+ binds = append(binds, cancelBinding)
1261+ }
1262+
12101263 if m.focus == uiFocusEditor {
12111264 tab.SetHelp("tab", "focus chat")
12121265 } else {
@@ -1272,6 +1325,17 @@ func (m *UI) FullHelp() [][]key.Binding {
12721325 k.Quit,
12731326 })
12741327 case uiChat:
1328+ // Show cancel binding if agent is busy.
1329+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1330+ cancelBinding := k.Chat.Cancel
1331+ if m.isCanceling {
1332+ cancelBinding.SetHelp("esc", "press again to cancel")
1333+ } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1334+ cancelBinding.SetHelp("esc", "clear queue")
1335+ }
1336+ binds = append(binds, []key.Binding{cancelBinding})
1337+ }
1338+
12751339 mainBinds := []key.Binding{}
12761340 tab := k.Tab
12771341 if m.focus == uiFocusEditor {
@@ -1800,6 +1864,46 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
18001864 return tea.Batch(cmds...)
18011865}
18021866
1867+const cancelTimerDuration = 2 * time.Second
1868+
1869+// cancelTimerCmd creates a command that expires the cancel timer.
1870+func cancelTimerCmd() tea.Cmd {
1871+ return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
1872+ return cancelTimerExpiredMsg{}
1873+ })
1874+}
1875+
1876+// cancelAgent handles the cancel key press. The first press sets isCanceling to true
1877+// and starts a timer. The second press (before the timer expires) actually
1878+// cancels the agent.
1879+func (m *UI) cancelAgent() tea.Cmd {
1880+ if m.session == nil || m.session.ID == "" {
1881+ return nil
1882+ }
1883+
1884+ coordinator := m.com.App.AgentCoordinator
1885+ if coordinator == nil {
1886+ return nil
1887+ }
1888+
1889+ if m.isCanceling {
1890+ // Second escape press - actually cancel the agent.
1891+ m.isCanceling = false
1892+ coordinator.Cancel(m.session.ID)
1893+ return nil
1894+ }
1895+
1896+ // Check if there are queued prompts - if so, clear the queue.
1897+ if coordinator.QueuedPrompts(m.session.ID) > 0 {
1898+ coordinator.ClearQueue(m.session.ID)
1899+ return nil
1900+ }
1901+
1902+ // First escape press - set canceling state and start timer.
1903+ m.isCanceling = true
1904+ return cancelTimerCmd()
1905+}
1906+
18031907// openDialog opens a dialog by its ID.
18041908func (m *UI) openDialog(id string) tea.Cmd {
18051909 var cmds []tea.Cmd
@@ -1906,6 +2010,38 @@ func (m *UI) openSessionsDialog() tea.Cmd {
19062010 return nil
19072011}
19082012
2013+// openPermissionsDialog opens the permissions dialog for a permission request.
2014+func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2015+ // Close any existing permissions dialog first.
2016+ m.dialog.CloseDialog(dialog.PermissionsID)
2017+
2018+ // Get diff mode from config.
2019+ var opts []dialog.PermissionsOption
2020+ if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2021+ opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2022+ }
2023+
2024+ permDialog := dialog.NewPermissions(m.com, perm, opts...)
2025+ m.dialog.OpenDialog(permDialog)
2026+ return nil
2027+}
2028+
2029+// handlePermissionNotification updates tool items when permission state changes.
2030+func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2031+ toolItem := m.chat.MessageItem(notification.ToolCallID)
2032+ if toolItem == nil {
2033+ return
2034+ }
2035+
2036+ if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2037+ if notification.Granted {
2038+ permItem.SetStatus(chat.ToolStatusRunning)
2039+ } else {
2040+ permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2041+ }
2042+ }
2043+}
2044+
19092045// newSession clears the current session state and prepares for a new session.
19102046// The actual session creation happens when the user sends their first message.
19112047func (m *UI) newSession() {
internal/ui/styles/styles.go+13
@@ -45,6 +45,9 @@ const (
4545
4646 ImageIcon string = "■"
4747 TextIcon string = "≡"
48+
49+ ScrollbarThumb string = "┃"
50+ ScrollbarTrack string = "│"
4851)
4952
5053const (
@@ -312,6 +315,13 @@ type Styles struct {
312315
313316 List lipgloss.Style
314317
318+ // ContentPanel is used for content blocks with subtle background.
319+ ContentPanel lipgloss.Style
320+
321+ // Scrollbar styles for scrollable content.
322+ ScrollbarThumb lipgloss.Style
323+ ScrollbarTrack lipgloss.Style
324+
315325 Commands struct{}
316326 }
317327
@@ -1162,6 +1172,9 @@ func DefaultStyles() Styles {
11621172 s.Dialog.InputPrompt = base.Margin(1, 1)
11631173
11641174 s.Dialog.List = base.Margin(0, 0, 1, 0)
1175+ s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1176+ s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1177+ s.Dialog.ScrollbarTrack = base.Foreground(border)
11651178
11661179 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
11671180 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")