Refactored permissions dialog (#1796)

Kujtim Hoxha and Ayman Bagabas created

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

Change summary

internal/ui/chat/agent.go         |  22 
internal/ui/chat/bash.go          |  22 
internal/ui/chat/diagnostics.go   |   6 
internal/ui/chat/fetch.go         |  18 
internal/ui/chat/file.go          |  28 
internal/ui/chat/search.go        |  24 
internal/ui/chat/todos.go         |   6 
internal/ui/chat/tools.go         | 137 +++--
internal/ui/common/scrollbar.go   |  46 ++
internal/ui/dialog/actions.go     |   5 
internal/ui/dialog/commands.go    |  17 
internal/ui/dialog/dialog.go      |  12 
internal/ui/dialog/models.go      |  14 
internal/ui/dialog/permissions.go | 695 +++++++++++++++++++++++++++++++++
internal/ui/dialog/sessions.go    |  12 
internal/ui/model/ui.go           | 136 ++++++
internal/ui/styles/styles.go      |  13 
17 files changed, 1,068 insertions(+), 145 deletions(-)

Detailed changes

internal/ui/chat/agent.go 🔗

@@ -47,14 +47,14 @@ func NewAgentToolMessageItem(
 	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
 	// For the agent tool we keep spinning until the tool call is finished.
 	t.spinningFunc = func(state SpinningState) bool {
-		return state.Result == nil && !state.Canceled
+		return !state.HasResult() && !state.IsCanceled()
 	}
 	return t
 }
 
 // Animate progresses the message animation if it should be spinning.
 func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
-	if a.result != nil || a.canceled {
+	if a.result != nil || a.Status() == ToolStatusCanceled {
 		return nil
 	}
 	if msg.ID == a.ID() {
@@ -100,7 +100,7 @@ type AgentToolRenderContext struct {
 // RenderTool implements the [ToolRenderer] interface.
 func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled && len(r.agent.nestedTools) == 0 {
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
 		return pendingTool(sty, "Agent", opts.Anim)
 	}
 
@@ -110,7 +110,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 	prompt := params.Prompt
 	prompt = strings.ReplaceAll(prompt, "\n", " ")
 
-	header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact)
+	header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
 	if opts.Compact {
 		return header
 	}
@@ -149,14 +149,14 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
 
 	// Show animation if still running.
-	if opts.Result == nil && !opts.Canceled {
+	if !opts.HasResult() && !opts.IsCanceled() {
 		parts = append(parts, "", opts.Anim.Render())
 	}
 
 	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
 
 	// Add body content when completed.
-	if opts.Result != nil && opts.Result.Content != "" {
+	if opts.HasResult() && opts.Result.Content != "" {
 		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
 		return joinToolParts(result, body)
 	}
@@ -191,7 +191,7 @@ func NewAgenticFetchToolMessageItem(
 	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
 	// For the agentic fetch tool we keep spinning until the tool call is finished.
 	t.spinningFunc = func(state SpinningState) bool {
-		return state.Result == nil && !state.Canceled
+		return !state.HasResult() && !state.IsCanceled()
 	}
 	return t
 }
@@ -231,7 +231,7 @@ type agenticFetchParams struct {
 // RenderTool implements the [ToolRenderer] interface.
 func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled && len(r.fetch.nestedTools) == 0 {
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
 		return pendingTool(sty, "Agentic Fetch", opts.Anim)
 	}
 
@@ -247,7 +247,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
 		toolParams = append(toolParams, params.URL)
 	}
 
-	header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -286,14 +286,14 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int
 	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
 
 	// Show animation if still running.
-	if opts.Result == nil && !opts.Canceled {
+	if !opts.HasResult() && !opts.IsCanceled() {
 		parts = append(parts, "", opts.Anim.Render())
 	}
 
 	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
 
 	// Add body content when completed.
-	if opts.Result != nil && opts.Result.Content != "" {
+	if opts.HasResult() && opts.Result.Content != "" {
 		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
 		return joinToolParts(result, body)
 	}

internal/ui/chat/bash.go 🔗

@@ -40,7 +40,7 @@ type BashToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Bash", opts.Anim)
 	}
 
@@ -51,7 +51,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 
 	// Check if this is a background job.
 	var meta tools.BashResponseMetadata
-	if opts.Result != nil {
+	if opts.HasResult() {
 		_ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
 	}
 
@@ -69,7 +69,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		toolParams = append(toolParams, "background", "true")
 	}
 
-	header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -78,7 +78,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil {
+	if !opts.HasResult() {
 		return header
 	}
 
@@ -122,7 +122,7 @@ type JobOutputToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Job", opts.Anim)
 	}
 
@@ -132,7 +132,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 	}
 
 	var description string
-	if opts.Result != nil && opts.Result.Metadata != "" {
+	if opts.HasResult() && opts.Result.Metadata != "" {
 		var meta tools.JobOutputResponseMetadata
 		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
 			description = cmp.Or(meta.Description, meta.Command)
@@ -140,7 +140,7 @@ func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 	}
 
 	content := ""
-	if opts.Result != nil {
+	if opts.HasResult() {
 		content = opts.Result.Content
 	}
 	return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
@@ -173,7 +173,7 @@ type JobKillToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Job", opts.Anim)
 	}
 
@@ -183,7 +183,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
 	}
 
 	var description string
-	if opts.Result != nil && opts.Result.Metadata != "" {
+	if opts.HasResult() && opts.Result.Metadata != "" {
 		var meta tools.JobKillResponseMetadata
 		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
 			description = cmp.Or(meta.Description, meta.Command)
@@ -191,7 +191,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
 	}
 
 	content := ""
-	if opts.Result != nil {
+	if opts.HasResult() {
 		content = opts.Result.Content
 	}
 	return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
@@ -200,7 +200,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
 // renderJobTool renders a job-related tool with the common pattern:
 // header → nested check → early state → body.
 func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
-	header := jobHeader(sty, opts.Status(), action, shellID, description, width)
+	header := jobHeader(sty, opts.Status, action, shellID, description, width)
 	if opts.Compact {
 		return header
 	}

internal/ui/chat/diagnostics.go 🔗

@@ -36,7 +36,7 @@ type DiagnosticsToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Diagnostics", opts.Anim)
 	}
 
@@ -49,7 +49,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
 		mainParam = fsext.PrettyPath(params.FilePath)
 	}
 
-	header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam)
+	header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam)
 	if opts.Compact {
 		return header
 	}
@@ -58,7 +58,7 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 

internal/ui/chat/fetch.go 🔗

@@ -35,7 +35,7 @@ type FetchToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Fetch", opts.Anim)
 	}
 
@@ -52,7 +52,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
 	}
 
-	header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -61,7 +61,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 
@@ -110,7 +110,7 @@ type WebFetchToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Fetch", opts.Anim)
 	}
 
@@ -120,7 +120,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op
 	}
 
 	toolParams := []string{params.URL}
-	header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -129,7 +129,7 @@ func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, op
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 
@@ -164,7 +164,7 @@ type WebSearchToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Search", opts.Anim)
 	}
 
@@ -174,7 +174,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 	}
 
 	toolParams := []string{params.Query}
-	header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -183,7 +183,7 @@ func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 

internal/ui/chat/file.go 🔗

@@ -38,7 +38,7 @@ type ViewToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "View", opts.Anim)
 	}
 
@@ -56,7 +56,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
 	}
 
-	header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -65,7 +65,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil {
+	if !opts.HasResult() {
 		return header
 	}
 
@@ -118,7 +118,7 @@ type WriteToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Write", opts.Anim)
 	}
 
@@ -128,7 +128,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 	}
 
 	file := fsext.PrettyPath(params.FilePath)
-	header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file)
+	header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file)
 	if opts.Compact {
 		return header
 	}
@@ -173,7 +173,7 @@ type EditToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	// Edit tool uses full width for diffs.
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Edit", opts.Anim)
 	}
 
@@ -183,7 +183,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 	}
 
 	file := fsext.PrettyPath(params.FilePath)
-	header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file)
+	header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file)
 	if opts.Compact {
 		return header
 	}
@@ -192,7 +192,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil {
+	if !opts.HasResult() {
 		return header
 	}
 
@@ -236,7 +236,7 @@ type MultiEditToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	// MultiEdit tool uses full width for diffs.
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Multi-Edit", opts.Anim)
 	}
 
@@ -251,7 +251,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 		toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
 	}
 
-	header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -260,7 +260,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil {
+	if !opts.HasResult() {
 		return header
 	}
 
@@ -304,7 +304,7 @@ type DownloadToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Download", opts.Anim)
 	}
 
@@ -321,7 +321,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
 		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
 	}
 
-	header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -330,7 +330,7 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 

internal/ui/chat/search.go 🔗

@@ -36,7 +36,7 @@ type GlobToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Glob", opts.Anim)
 	}
 
@@ -50,7 +50,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		toolParams = append(toolParams, "path", params.Path)
 	}
 
-	header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -59,7 +59,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if !opts.HasResult() || opts.Result.Content == "" {
 		return header
 	}
 
@@ -95,7 +95,7 @@ type GrepToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Grep", opts.Anim)
 	}
 
@@ -115,7 +115,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		toolParams = append(toolParams, "literal", "true")
 	}
 
-	header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -124,7 +124,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 
@@ -160,7 +160,7 @@ type LSToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "List", opts.Anim)
 	}
 
@@ -175,7 +175,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
 	}
 	path = fsext.PrettyPath(path)
 
-	header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
+	header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path)
 	if opts.Compact {
 		return header
 	}
@@ -184,7 +184,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 
@@ -220,7 +220,7 @@ type SourcegraphToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "Sourcegraph", opts.Anim)
 	}
 
@@ -237,7 +237,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
 		toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
 	}
 
-	header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}
@@ -246,7 +246,7 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
 		return joinToolParts(header, earlyState)
 	}
 
-	if opts.Result == nil || opts.Result.Content == "" {
+	if opts.HasEmptyResult() {
 		return header
 	}
 

internal/ui/chat/todos.go 🔗

@@ -40,7 +40,7 @@ type TodosToolRenderContext struct{}
 // RenderTool implements the [ToolRenderer] interface.
 func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 	cappedWidth := cappedMessageWidth(width)
-	if !opts.ToolCall.Finished && !opts.Canceled {
+	if opts.IsPending() {
 		return pendingTool(sty, "To-Do", opts.Anim)
 	}
 
@@ -74,7 +74,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 		}
 
 		// If we have metadata, use it for richer display.
-		if opts.Result != nil && opts.Result.Metadata != "" {
+		if opts.HasResult() && opts.Result.Metadata != "" {
 			if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
 				if meta.IsNew {
 					if meta.JustStarted != "" {
@@ -119,7 +119,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 	}
 
 	toolParams := []string{headerText}
-	header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...)
+	header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
 	if opts.Compact {
 		return header
 	}

internal/ui/chat/tools.go 🔗

@@ -42,6 +42,8 @@ type ToolMessageItem interface {
 	SetResult(res *message.ToolResult)
 	MessageID() string
 	SetMessageID(id string)
+	SetStatus(status ToolStatus)
+	Status() ToolStatus
 }
 
 // Compactable is an interface for tool items that can render in a compacted mode.
@@ -54,7 +56,17 @@ type Compactable interface {
 type SpinningState struct {
 	ToolCall message.ToolCall
 	Result   *message.ToolResult
-	Canceled bool
+	Status   ToolStatus
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (s *SpinningState) IsCanceled() bool {
+	return s.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (s *SpinningState) HasResult() bool {
+	return s.Result != nil
 }
 
 // SpinningFunc is a function type for custom spinning logic.
@@ -71,32 +83,34 @@ func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
 
 // ToolRenderOpts contains the data needed to render a tool call.
 type ToolRenderOpts struct {
-	ToolCall            message.ToolCall
-	Result              *message.ToolResult
-	Canceled            bool
-	Anim                *anim.Anim
-	ExpandedContent     bool
-	Compact             bool
-	IsSpinning          bool
-	PermissionRequested bool
-	PermissionGranted   bool
-}
-
-// Status returns the current status of the tool call.
-func (opts *ToolRenderOpts) Status() ToolStatus {
-	if opts.Canceled && opts.Result == nil {
-		return ToolStatusCanceled
-	}
-	if opts.Result != nil {
-		if opts.Result.IsError {
-			return ToolStatusError
-		}
-		return ToolStatusSuccess
-	}
-	if opts.PermissionRequested && !opts.PermissionGranted {
-		return ToolStatusAwaitingPermission
-	}
-	return ToolStatusRunning
+	ToolCall        message.ToolCall
+	Result          *message.ToolResult
+	Anim            *anim.Anim
+	ExpandedContent bool
+	Compact         bool
+	IsSpinning      bool
+	Status          ToolStatus
+}
+
+// IsPending returns true if the tool call is still pending (not finished and
+// not canceled).
+func (o *ToolRenderOpts) IsPending() bool {
+	return !o.ToolCall.Finished && !o.IsCanceled()
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (o *ToolRenderOpts) IsCanceled() bool {
+	return o.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (o *ToolRenderOpts) HasResult() bool {
+	return o.Result != nil
+}
+
+// HasEmptyResult returns true if the result is nil or has empty content.
+func (o *ToolRenderOpts) HasEmptyResult() bool {
+	return o.Result == nil || o.Result.Content == ""
 }
 
 // ToolRenderer represents an interface for rendering tool calls.
@@ -118,13 +132,11 @@ type baseToolMessageItem struct {
 	*cachedMessageItem
 	*focusableMessageItem
 
-	toolRenderer        ToolRenderer
-	toolCall            message.ToolCall
-	result              *message.ToolResult
-	messageID           string
-	canceled            bool
-	permissionRequested bool
-	permissionGranted   bool
+	toolRenderer ToolRenderer
+	toolCall     message.ToolCall
+	result       *message.ToolResult
+	messageID    string
+	status       ToolStatus
 	// we use this so we can efficiently cache
 	// tools that have a capped width (e.x bash.. and others)
 	hasCappedWidth bool
@@ -150,6 +162,11 @@ func newBaseToolMessageItem(
 	// we only do full width for diffs (as far as I know)
 	hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
 
+	status := ToolStatusRunning
+	if canceled {
+		status = ToolStatusCanceled
+	}
+
 	t := &baseToolMessageItem{
 		highlightableMessageItem: defaultHighlighter(sty),
 		cachedMessageItem:        &cachedMessageItem{},
@@ -158,7 +175,7 @@ func newBaseToolMessageItem(
 		toolRenderer:             toolRenderer,
 		toolCall:                 toolCall,
 		result:                   result,
-		canceled:                 canceled,
+		status:                   status,
 		hasCappedWidth:           hasCappedWidth,
 	}
 	t.anim = anim.New(anim.Settings{
@@ -285,15 +302,13 @@ func (t *baseToolMessageItem) Render(width int) string {
 	// if we are spinning or there is no cache rerender
 	if !ok || t.isSpinning() {
 		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
-			ToolCall:            t.toolCall,
-			Result:              t.result,
-			Canceled:            t.canceled,
-			Anim:                t.anim,
-			ExpandedContent:     t.expandedContent,
-			Compact:             t.isCompact,
-			PermissionRequested: t.permissionRequested,
-			PermissionGranted:   t.permissionGranted,
-			IsSpinning:          t.isSpinning(),
+			ToolCall:        t.toolCall,
+			Result:          t.result,
+			Anim:            t.anim,
+			ExpandedContent: t.expandedContent,
+			Compact:         t.isCompact,
+			IsSpinning:      t.isSpinning(),
+			Status:          t.computeStatus(),
 		})
 		height = lipgloss.Height(content)
 		// cache the rendered content
@@ -331,20 +346,26 @@ func (t *baseToolMessageItem) SetMessageID(id string) {
 	t.messageID = id
 }
 
-// SetPermissionRequested sets whether permission has been requested for this tool call.
-// TODO: Consider merging with SetPermissionGranted and add an interface for
-// permission management.
-func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
-	t.permissionRequested = requested
+// SetStatus sets the tool status.
+func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
+	t.status = status
 	t.clearCache()
 }
 
-// SetPermissionGranted sets whether permission has been granted for this tool call.
-// TODO: Consider merging with SetPermissionRequested and add an interface for
-// permission management.
-func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
-	t.permissionGranted = granted
-	t.clearCache()
+// Status returns the current tool status.
+func (t *baseToolMessageItem) Status() ToolStatus {
+	return t.status
+}
+
+// computeStatus computes the effective status considering the result.
+func (t *baseToolMessageItem) computeStatus() ToolStatus {
+	if t.result != nil {
+		if t.result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	return t.status
 }
 
 // isSpinning returns true if the tool should show animation.
@@ -353,10 +374,10 @@ func (t *baseToolMessageItem) isSpinning() bool {
 		return t.spinningFunc(SpinningState{
 			ToolCall: t.toolCall,
 			Result:   t.result,
-			Canceled: t.canceled,
+			Status:   t.status,
 		})
 	}
-	return !t.toolCall.Finished && !t.canceled
+	return !t.toolCall.Finished && t.status != ToolStatusCanceled
 }
 
 // SetSpinningFunc sets a custom function to determine if the tool should spin.
@@ -396,7 +417,7 @@ func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
 // Returns the rendered output and true if early state was handled.
 func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
 	var msg string
-	switch opts.Status() {
+	switch opts.Status {
 	case ToolStatusError:
 		msg = toolErrorContent(sty, opts.Result, width)
 	case ToolStatusCanceled:

internal/ui/common/scrollbar.go 🔗

@@ -0,0 +1,46 @@
+package common
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// Scrollbar renders a vertical scrollbar based on content and viewport size.
+// Returns an empty string if content fits within viewport (no scrolling needed).
+func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string {
+	if height <= 0 || contentSize <= viewportSize {
+		return ""
+	}
+
+	// Calculate thumb size (minimum 1 character).
+	thumbSize := max(1, height*viewportSize/contentSize)
+
+	// Calculate thumb position.
+	maxOffset := contentSize - viewportSize
+	if maxOffset <= 0 {
+		return ""
+	}
+
+	// Calculate where the thumb starts.
+	trackSpace := height - thumbSize
+	thumbPos := 0
+	if trackSpace > 0 && maxOffset > 0 {
+		thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
+	}
+
+	// Build the scrollbar.
+	var sb strings.Builder
+	for i := range height {
+		if i > 0 {
+			sb.WriteString("\n")
+		}
+		if i >= thumbPos && i < thumbPos+thumbSize {
+			sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
+		} else {
+			sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
+		}
+	}
+
+	return sb.String()
+}

internal/ui/dialog/actions.go 🔗

@@ -3,6 +3,7 @@ package dialog
 import (
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
 )
 
@@ -39,6 +40,10 @@ type (
 	ActionSummarize         struct {
 		SessionID string
 	}
+	ActionPermissionResponse struct {
+		Permission permission.PermissionRequest
+		Action     PermissionAction
+	}
 )
 
 // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the

internal/ui/dialog/commands.go 🔗

@@ -220,16 +220,15 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
 // Draw implements [Dialog].
 func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
-	width := max(0, min(100, area.Dx()))
-	height := max(0, min(30, area.Dy()))
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
 	c.width = width
-	// TODO: Why do we need this 2?
-	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
 		t.Dialog.HelpView.GetVerticalFrameSize() +
-		// TODO: Why do we need this 2?
-		t.Dialog.View.GetVerticalFrameSize() + 2
+		t.Dialog.View.GetVerticalFrameSize()
+
 	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
 	c.list.SetSize(innerWidth, height-heightOffset)
 	c.help.SetWidth(innerWidth)
@@ -416,7 +415,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 		cfg := c.com.Config()
 		agentCfg := cfg.Agents[config.AgentCoder]
 		model := cfg.GetModelByType(agentCfg.Model)
-		if model.SupportsImages {
+		if model != nil && model.SupportsImages {
 			commands = append(commands, uicmd.Command{
 				ID:          "file_picker",
 				Title:       "Open File Picker",

internal/ui/dialog/dialog.go 🔗

@@ -8,6 +8,18 @@ import (
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
+// Dialog sizing constants.
+const (
+	// defaultDialogMaxWidth is the maximum width for standard dialogs.
+	defaultDialogMaxWidth = 120
+	// defaultDialogHeight is the default height for standard dialogs.
+	defaultDialogHeight = 30
+	// titleContentHeight is the height of the title content line.
+	titleContentHeight = 1
+	// inputContentHeight is the height of the input content line.
+	inputContentHeight = 1
+)
+
 // CloseKey is the default key binding to close dialogs.
 var CloseKey = key.NewBinding(
 	key.WithKeys("esc", "alt+esc"),

internal/ui/dialog/models.go 🔗

@@ -241,15 +241,13 @@ func (m *Models) modelTypeRadioView() string {
 // Draw implements [Dialog].
 func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := m.com.Styles
-	width := max(0, min(60, area.Dx()))
-	height := max(0, min(30, area.Dy()))
-	// TODO: Why do we need this 2?
-	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
 		t.Dialog.HelpView.GetVerticalFrameSize() +
-		// TODO: Why do we need this 2?
-		t.Dialog.View.GetVerticalFrameSize() + 2
+		t.Dialog.View.GetVerticalFrameSize()
 	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
 	m.list.SetSize(innerWidth, height-heightOffset)
 	m.help.SetWidth(innerWidth)

internal/ui/dialog/permissions.go 🔗

@@ -0,0 +1,695 @@
+package dialog
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// PermissionsID is the identifier for the permissions dialog.
+const PermissionsID = "permissions"
+
+// PermissionAction represents the user's response to a permission request.
+type PermissionAction string
+
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+)
+
+// Permissions dialog sizing constants.
+const (
+	// diffMaxWidth is the maximum width for diff views.
+	diffMaxWidth = 180
+	// diffSizeRatio is the size ratio for diff views relative to window.
+	diffSizeRatio = 0.8
+	// simpleMaxWidth is the maximum width for simple content dialogs.
+	simpleMaxWidth = 100
+	// simpleSizeRatio is the size ratio for simple content dialogs.
+	simpleSizeRatio = 0.6
+	// simpleHeightRatio is the height ratio for simple content dialogs.
+	simpleHeightRatio = 0.5
+	// splitModeMinWidth is the minimum width to enable split diff mode.
+	splitModeMinWidth = 140
+	// layoutSpacingLines is the number of empty lines used for layout spacing.
+	layoutSpacingLines = 4
+	// minWindowWidth is the minimum window width before forcing fullscreen.
+	minWindowWidth = 60
+	// minWindowHeight is the minimum window height before forcing fullscreen.
+	minWindowHeight = 20
+)
+
+// Permissions represents a dialog for permission requests.
+type Permissions struct {
+	com          *common.Common
+	windowWidth  int // Terminal window dimensions.
+	windowHeight int
+	fullscreen   bool // true when dialog is fullscreen
+
+	permission     permission.PermissionRequest
+	selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
+
+	viewport      viewport.Model
+	viewportDirty bool // true when viewport content needs to be re-rendered
+	viewportWidth int
+
+	// Diff view state.
+	diffSplitMode        *bool // nil means use default based on width
+	defaultDiffSplitMode bool  // default split mode based on width
+	unifiedDiffContent   string
+	splitDiffContent     string
+
+	help   help.Model
+	keyMap permissionsKeyMap
+}
+
+type permissionsKeyMap struct {
+	Left             key.Binding
+	Right            key.Binding
+	Tab              key.Binding
+	Select           key.Binding
+	Allow            key.Binding
+	AllowSession     key.Binding
+	Deny             key.Binding
+	Close            key.Binding
+	ToggleDiffMode   key.Binding
+	ToggleFullscreen key.Binding
+	ScrollUp         key.Binding
+	ScrollDown       key.Binding
+	ScrollLeft       key.Binding
+	ScrollRight      key.Binding
+	Choose           key.Binding
+	Scroll           key.Binding
+}
+
+func defaultPermissionsKeyMap() permissionsKeyMap {
+	return permissionsKeyMap{
+		Left: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("←", "previous"),
+		),
+		Right: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("→", "next"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "next option"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Allow: key.NewBinding(
+			key.WithKeys("a", "A", "ctrl+a"),
+			key.WithHelp("a", "allow"),
+		),
+		AllowSession: key.NewBinding(
+			key.WithKeys("s", "S", "ctrl+s"),
+			key.WithHelp("s", "allow session"),
+		),
+		Deny: key.NewBinding(
+			key.WithKeys("d", "D"),
+			key.WithHelp("d", "deny"),
+		),
+		Close: CloseKey,
+		ToggleDiffMode: key.NewBinding(
+			key.WithKeys("t"),
+			key.WithHelp("t", "toggle diff view"),
+		),
+		ToggleFullscreen: key.NewBinding(
+			key.WithKeys("f"),
+			key.WithHelp("f", "toggle fullscreen"),
+		),
+		ScrollUp: key.NewBinding(
+			key.WithKeys("shift+up", "K"),
+			key.WithHelp("shift+↑", "scroll up"),
+		),
+		ScrollDown: key.NewBinding(
+			key.WithKeys("shift+down", "J"),
+			key.WithHelp("shift+↓", "scroll down"),
+		),
+		ScrollLeft: key.NewBinding(
+			key.WithKeys("shift+left", "H"),
+			key.WithHelp("shift+←", "scroll left"),
+		),
+		ScrollRight: key.NewBinding(
+			key.WithKeys("shift+right", "L"),
+			key.WithHelp("shift+→", "scroll right"),
+		),
+		Choose: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "choose"),
+		),
+		Scroll: key.NewBinding(
+			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
+			key.WithHelp("shift+←↓↑→", "scroll"),
+		),
+	}
+}
+
+var _ Dialog = (*Permissions)(nil)
+
+// PermissionsOption configures the permissions dialog.
+type PermissionsOption func(*Permissions)
+
+// WithDiffMode sets the initial diff mode (split or unified).
+func WithDiffMode(split bool) PermissionsOption {
+	return func(p *Permissions) {
+		p.diffSplitMode = &split
+	}
+}
+
+// NewPermissions creates a new permissions dialog.
+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
+	h := help.New()
+	h.Styles = com.Styles.DialogHelpStyles()
+
+	km := defaultPermissionsKeyMap()
+
+	// Configure viewport with matching keybindings.
+	vp := viewport.New()
+	vp.KeyMap = viewport.KeyMap{
+		Up:    km.ScrollUp,
+		Down:  km.ScrollDown,
+		Left:  km.ScrollLeft,
+		Right: km.ScrollRight,
+		// Disable other viewport keys to avoid conflicts with dialog shortcuts.
+		PageUp:       key.NewBinding(key.WithDisabled()),
+		PageDown:     key.NewBinding(key.WithDisabled()),
+		HalfPageUp:   key.NewBinding(key.WithDisabled()),
+		HalfPageDown: key.NewBinding(key.WithDisabled()),
+	}
+
+	p := &Permissions{
+		com:            com,
+		permission:     perm,
+		selectedOption: 0,
+		viewport:       vp,
+		help:           h,
+		keyMap:         km,
+	}
+
+	for _, opt := range opts {
+		opt(p)
+	}
+
+	return p
+}
+
+// Calculate usable content width (dialog border + horizontal padding).
+func (p *Permissions) calculateContentWidth(width int) int {
+	t := p.com.Styles
+	const dialogHorizontalPadding = 2
+	return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
+}
+
+// ID implements [Dialog].
+func (*Permissions) ID() string {
+	return PermissionsID
+}
+
+// HandleMsg implements [Dialog].
+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Close):
+			// Escape denies the permission request.
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
+			p.selectedOption = (p.selectedOption + 1) % 3
+		case key.Matches(msg, p.keyMap.Left):
+			// Add 2 instead of subtracting 1 to avoid negative modulo.
+			p.selectedOption = (p.selectedOption + 2) % 3
+		case key.Matches(msg, p.keyMap.Select):
+			return p.selectCurrentOption()
+		case key.Matches(msg, p.keyMap.Allow):
+			return p.respond(PermissionAllow)
+		case key.Matches(msg, p.keyMap.AllowSession):
+			return p.respond(PermissionAllowForSession)
+		case key.Matches(msg, p.keyMap.Deny):
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.ToggleDiffMode):
+			if p.hasDiffView() {
+				newMode := !p.isSplitMode()
+				p.diffSplitMode = &newMode
+				p.viewportDirty = true
+			}
+		case key.Matches(msg, p.keyMap.ToggleFullscreen):
+			if p.hasDiffView() {
+				p.fullscreen = !p.fullscreen
+			}
+		case key.Matches(msg, p.keyMap.ScrollDown):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollUp):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollLeft):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollRight):
+			p.viewport, _ = p.viewport.Update(msg)
+		}
+	case tea.MouseWheelMsg:
+		p.viewport, _ = p.viewport.Update(msg)
+	default:
+		// Pass unhandled keys to viewport for non-diff content scrolling.
+		if !p.hasDiffView() {
+			p.viewport, _ = p.viewport.Update(msg)
+			p.viewportDirty = true
+		}
+	}
+
+	return nil
+}
+
+func (p *Permissions) selectCurrentOption() tea.Msg {
+	switch p.selectedOption {
+	case 0:
+		return p.respond(PermissionAllow)
+	case 1:
+		return p.respond(PermissionAllowForSession)
+	default:
+		return p.respond(PermissionDeny)
+	}
+}
+
+func (p *Permissions) respond(action PermissionAction) tea.Msg {
+	return ActionPermissionResponse{
+		Permission: p.permission,
+		Action:     action,
+	}
+}
+
+func (p *Permissions) hasDiffView() bool {
+	switch p.permission.ToolName {
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
+		return true
+	}
+	return false
+}
+
+func (p *Permissions) isSplitMode() bool {
+	if p.diffSplitMode != nil {
+		return *p.diffSplitMode
+	}
+	return p.defaultDiffSplitMode
+}
+
+// Draw implements [Dialog].
+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := p.com.Styles
+	// Force fullscreen when window is too small.
+	forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
+
+	// Calculate dialog dimensions based on fullscreen state and content type.
+	var width, height int
+	if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
+		// Use nearly full window for fullscreen.
+		width = area.Dx()
+		height = area.Dy()
+	} else if p.hasDiffView() {
+		// Wide for side-by-side diffs, capped for readability.
+		width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
+		height = int(float64(area.Dy()) * diffSizeRatio)
+	} else {
+		// Narrower for simple content like commands/URLs.
+		width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
+		height = int(float64(area.Dy()) * simpleHeightRatio)
+	}
+
+	dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
+
+	contentWidth := p.calculateContentWidth(width)
+	header := p.renderHeader(contentWidth)
+	buttons := p.renderButtons(contentWidth)
+	helpView := p.help.View(p)
+
+	// Calculate available height for content.
+	headerHeight := lipgloss.Height(header)
+	buttonsHeight := lipgloss.Height(buttons)
+	helpHeight := lipgloss.Height(helpView)
+	frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
+	availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight
+
+	p.defaultDiffSplitMode = width >= splitModeMinWidth
+
+	if p.viewport.Width() != contentWidth-1 {
+		// Mark diff content as dirty if width has changed
+		p.viewportDirty = true
+	}
+
+	var content string
+	var scrollbar string
+	// Non-diff content uses the viewport for scrolling.
+	p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar
+	p.viewport.SetHeight(availableHeight)
+	if p.viewportDirty {
+		p.viewport.SetContent(p.renderContent(contentWidth - 1))
+		p.viewportWidth = p.viewport.Width()
+		p.viewportDirty = false
+	}
+	content = p.viewport.View()
+	if p.canScroll() {
+		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
+	}
+
+	// Join content with scrollbar if present.
+	if scrollbar != "" {
+		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+	}
+
+	parts := []string{header}
+	if content != "" {
+		parts = append(parts, "", content)
+	}
+	parts = append(parts, "", buttons, "", helpView)
+
+	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
+	return nil
+}
+
+func (p *Permissions) renderHeader(contentWidth int) string {
+	t := p.com.Styles
+
+	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
+	title = t.Dialog.Title.Render(title)
+
+	// Tool info.
+	toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
+	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
+
+	lines := []string{title, "", toolLine, pathLine}
+
+	// Add tool-specific header info.
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
+		}
+	case tools.DownloadToolName:
+		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
+		}
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
+		var filePath string
+		switch params := p.permission.Params.(type) {
+		case tools.EditPermissionsParams:
+			filePath = params.FilePath
+		case tools.WritePermissionsParams:
+			filePath = params.FilePath
+		case tools.MultiEditPermissionsParams:
+			filePath = params.FilePath
+		case tools.ViewPermissionsParams:
+			filePath = params.FilePath
+		}
+		if filePath != "" {
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
+		}
+	case tools.LSToolName:
+		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
+		}
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (p *Permissions) renderKeyValue(key, value string, width int) string {
+	t := p.com.Styles
+	keyStyle := t.Muted
+	valueStyle := t.Base
+
+	keyStr := keyStyle.Render(key)
+	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
+}
+
+func (p *Permissions) renderContent(width int) string {
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		return p.renderBashContent()
+	case tools.EditToolName:
+		return p.renderEditContent(width)
+	case tools.WriteToolName:
+		return p.renderWriteContent(width)
+	case tools.MultiEditToolName:
+		return p.renderMultiEditContent(width)
+	case tools.DownloadToolName:
+		return p.renderDownloadContent()
+	case tools.FetchToolName:
+		return p.renderFetchContent()
+	case tools.AgenticFetchToolName:
+		return p.renderAgenticFetchContent()
+	case tools.ViewToolName:
+		return p.renderViewContent()
+	case tools.LSToolName:
+		return p.renderLSContent()
+	default:
+		return p.renderDefaultContent()
+	}
+}
+
+func (p *Permissions) renderBashContent() string {
+	params, ok := p.permission.Params.(tools.BashPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
+}
+
+func (p *Permissions) renderEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.EditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderWriteContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.WritePermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
+	if !p.viewportDirty {
+		if p.isSplitMode() {
+			return p.splitDiffContent
+		}
+		return p.unifiedDiffContent
+	}
+
+	isSplitMode := p.isSplitMode()
+	formatter := common.DiffFormatter(p.com.Styles).
+		Before(fsext.PrettyPath(filePath), oldContent).
+		After(fsext.PrettyPath(filePath), newContent).
+		// TODO: Allow horizontal scrolling instead of cropping. However, the
+		// diffview currently would only background color the width of the
+		// content. If the viewport is wider than the content, the rest of the
+		// line would not be colored properly.
+		Width(contentWidth)
+
+	var result string
+	if isSplitMode {
+		formatter = formatter.Split()
+		p.splitDiffContent = formatter.String()
+		result = p.splitDiffContent
+	} else {
+		formatter = formatter.Unified()
+		p.unifiedDiffContent = formatter.String()
+		result = p.unifiedDiffContent
+	}
+
+	return result
+}
+
+func (p *Permissions) renderDownloadContent() string {
+	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
+	if params.Timeout > 0 {
+		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(content)
+}
+
+func (p *Permissions) renderFetchContent() string {
+	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
+}
+
+func (p *Permissions) renderAgenticFetchContent() string {
+	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	var content string
+	if params.URL != "" {
+		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
+	} else {
+		content = fmt.Sprintf("Prompt: %s", params.Prompt)
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(content)
+}
+
+func (p *Permissions) renderViewContent() string {
+	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
+	if params.Offset > 0 {
+		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
+	}
+	if params.Limit > 0 && params.Limit != 2000 {
+		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(content)
+}
+
+func (p *Permissions) renderLSContent() string {
+	params, ok := p.permission.Params.(tools.LSPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
+	if len(params.Ignore) > 0 {
+		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(content)
+}
+
+func (p *Permissions) renderDefaultContent() string {
+	content := p.permission.Description
+
+	// Pretty-print JSON params if available.
+	if p.permission.Params != nil {
+		var paramStr string
+		if str, ok := p.permission.Params.(string); ok {
+			paramStr = str
+		} else {
+			paramStr = fmt.Sprintf("%v", p.permission.Params)
+		}
+
+		var parsed any
+		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
+			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+				if content != "" {
+					content += "\n\n"
+				}
+				content += string(b)
+			}
+		} else if paramStr != "" {
+			if content != "" {
+				content += "\n\n"
+			}
+			content += paramStr
+		}
+	}
+
+	if content == "" {
+		return ""
+	}
+
+	return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
+}
+
+func (p *Permissions) renderButtons(contentWidth int) string {
+	buttons := []common.ButtonOpts{
+		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
+		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
+		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
+	}
+
+	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
+
+	// If buttons are too wide, stack them vertically.
+	if lipgloss.Width(content) > contentWidth {
+		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
+		return lipgloss.NewStyle().
+			Width(contentWidth).
+			Align(lipgloss.Center).
+			Render(content)
+	}
+
+	return lipgloss.NewStyle().
+		Width(contentWidth).
+		Align(lipgloss.Right).
+		Render(content)
+}
+
+func (p *Permissions) canScroll() bool {
+	if p.hasDiffView() {
+		// Diff views can always scroll.
+		return true
+	}
+	// For non-diff content, check if viewport has scrollable content.
+	return !p.viewport.AtTop() || !p.viewport.AtBottom()
+}
+
+// ShortHelp implements [help.KeyMap].
+func (p *Permissions) ShortHelp() []key.Binding {
+	bindings := []key.Binding{
+		p.keyMap.Choose,
+		p.keyMap.Select,
+		p.keyMap.Close,
+	}
+
+	if p.canScroll() {
+		bindings = append(bindings, p.keyMap.Scroll)
+	}
+
+	if p.hasDiffView() {
+		bindings = append(bindings,
+			p.keyMap.ToggleDiffMode,
+			p.keyMap.ToggleFullscreen,
+		)
+	}
+
+	return bindings
+}
+
+// FullHelp implements [help.KeyMap].
+func (p *Permissions) FullHelp() [][]key.Binding {
+	return [][]key.Binding{p.ShortHelp()}
+}

internal/ui/dialog/sessions.go 🔗

@@ -143,15 +143,13 @@ func (s *Session) Cursor() *tea.Cursor {
 // Draw implements [Dialog].
 func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := s.com.Styles
-	width := max(0, min(120, area.Dx()))
-	height := max(0, min(30, area.Dy()))
-	// TODO: Why do we need this 2?
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
 	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
-	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
-		t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
 		t.Dialog.HelpView.GetVerticalFrameSize() +
-		// TODO: Why do we need this 2?
-		t.Dialog.View.GetVerticalFrameSize() + 2
+		t.Dialog.View.GetVerticalFrameSize()
 	s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
 	s.list.SetSize(innerWidth, height-heightOffset)
 	s.help.SetWidth(innerWidth)

internal/ui/model/ui.go 🔗

@@ -13,6 +13,7 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"time"
 
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
@@ -74,6 +75,8 @@ type openEditorMsg struct {
 	Text string
 }
 
+type cancelTimerExpiredMsg struct{}
+
 // UI represents the main user interface model.
 type UI struct {
 	com          *common.Common
@@ -94,6 +97,9 @@ type UI struct {
 	dialog *dialog.Overlay
 	status *Status
 
+	// isCanceling tracks whether the user has pressed escape once to cancel.
+	isCanceling bool
+
 	// header is the last cached header logo
 	header string
 
@@ -280,6 +286,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 		}
+	case pubsub.Event[permission.PermissionRequest]:
+		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case pubsub.Event[permission.PermissionNotification]:
+		m.handlePermissionNotification(msg.Payload)
+	case cancelTimerExpiredMsg:
+		m.isCanceling = false
 	case tea.TerminalVersionMsg:
 		termVersion := strings.ToLower(msg.Name)
 		// Only enable progress bar for the following terminals.
@@ -348,6 +362,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.chat.HandleMouseUp(x, y)
 		}
 	case tea.MouseWheelMsg:
+		// Pass mouse events to dialogs first if any are open.
+		if m.dialog.HasDialogs() {
+			m.dialog.Update(msg)
+			return m, tea.Batch(cmds...)
+		}
+
+		// Otherwise handle mouse wheel for chat.
 		switch m.state {
 		case uiChat:
 			switch msg.Button {
@@ -778,6 +799,17 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 		m.dialog.CloseDialog(dialog.ModelsID)
+		// TODO CHANGE
+	case dialog.ActionPermissionResponse:
+		m.dialog.CloseDialog(dialog.PermissionsID)
+		switch msg.Action {
+		case dialog.PermissionAllow:
+			m.com.App.Permissions.Grant(msg.Permission)
+		case dialog.PermissionAllowForSession:
+			m.com.App.Permissions.GrantPersistent(msg.Permission)
+		case dialog.PermissionDeny:
+			m.com.App.Permissions.Deny(msg.Permission)
+		}
 	}
 
 	return tea.Batch(cmds...)
@@ -825,6 +857,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		return m.handleDialogMsg(msg)
 	}
 
+	// Handle cancel key when agent is busy.
+	if key.Matches(msg, m.keyMap.Chat.Cancel) {
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			if cmd := m.cancelAgent(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return tea.Batch(cmds...)
+		}
+	}
+
 	switch m.state {
 	case uiConfigure:
 		return tea.Batch(cmds...)
@@ -1207,6 +1249,17 @@ func (m *UI) ShortHelp() []key.Binding {
 	case uiInitialize:
 		binds = append(binds, k.Quit)
 	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, cancelBinding)
+		}
+
 		if m.focus == uiFocusEditor {
 			tab.SetHelp("tab", "focus chat")
 		} else {
@@ -1272,6 +1325,17 @@ func (m *UI) FullHelp() [][]key.Binding {
 				k.Quit,
 			})
 	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, []key.Binding{cancelBinding})
+		}
+
 		mainBinds := []key.Binding{}
 		tab := k.Tab
 		if m.focus == uiFocusEditor {
@@ -1800,6 +1864,46 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
 	return tea.Batch(cmds...)
 }
 
+const cancelTimerDuration = 2 * time.Second
+
+// cancelTimerCmd creates a command that expires the cancel timer.
+func cancelTimerCmd() tea.Cmd {
+	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
+		return cancelTimerExpiredMsg{}
+	})
+}
+
+// cancelAgent handles the cancel key press. The first press sets isCanceling to true
+// and starts a timer. The second press (before the timer expires) actually
+// cancels the agent.
+func (m *UI) cancelAgent() tea.Cmd {
+	if m.session == nil || m.session.ID == "" {
+		return nil
+	}
+
+	coordinator := m.com.App.AgentCoordinator
+	if coordinator == nil {
+		return nil
+	}
+
+	if m.isCanceling {
+		// Second escape press - actually cancel the agent.
+		m.isCanceling = false
+		coordinator.Cancel(m.session.ID)
+		return nil
+	}
+
+	// Check if there are queued prompts - if so, clear the queue.
+	if coordinator.QueuedPrompts(m.session.ID) > 0 {
+		coordinator.ClearQueue(m.session.ID)
+		return nil
+	}
+
+	// First escape press - set canceling state and start timer.
+	m.isCanceling = true
+	return cancelTimerCmd()
+}
+
 // openDialog opens a dialog by its ID.
 func (m *UI) openDialog(id string) tea.Cmd {
 	var cmds []tea.Cmd
@@ -1906,6 +2010,38 @@ func (m *UI) openSessionsDialog() tea.Cmd {
 	return nil
 }
 
+// openPermissionsDialog opens the permissions dialog for a permission request.
+func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
+	// Close any existing permissions dialog first.
+	m.dialog.CloseDialog(dialog.PermissionsID)
+
+	// Get diff mode from config.
+	var opts []dialog.PermissionsOption
+	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
+		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
+	}
+
+	permDialog := dialog.NewPermissions(m.com, perm, opts...)
+	m.dialog.OpenDialog(permDialog)
+	return nil
+}
+
+// handlePermissionNotification updates tool items when permission state changes.
+func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
+	toolItem := m.chat.MessageItem(notification.ToolCallID)
+	if toolItem == nil {
+		return
+	}
+
+	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
+		if notification.Granted {
+			permItem.SetStatus(chat.ToolStatusRunning)
+		} else {
+			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
+		}
+	}
+}
+
 // newSession clears the current session state and prepares for a new session.
 // The actual session creation happens when the user sends their first message.
 func (m *UI) newSession() {

internal/ui/styles/styles.go 🔗

@@ -45,6 +45,9 @@ const (
 
 	ImageIcon string = "■"
 	TextIcon  string = "≡"
+
+	ScrollbarThumb string = "┃"
+	ScrollbarTrack string = "│"
 )
 
 const (
@@ -312,6 +315,13 @@ type Styles struct {
 
 		List lipgloss.Style
 
+		// ContentPanel is used for content blocks with subtle background.
+		ContentPanel lipgloss.Style
+
+		// Scrollbar styles for scrollable content.
+		ScrollbarThumb lipgloss.Style
+		ScrollbarTrack lipgloss.Style
+
 		Commands struct{}
 	}
 
@@ -1162,6 +1172,9 @@ func DefaultStyles() Styles {
 	s.Dialog.InputPrompt = base.Margin(1, 1)
 
 	s.Dialog.List = base.Margin(0, 0, 1, 0)
+	s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
+	s.Dialog.ScrollbarThumb = base.Foreground(secondary)
+	s.Dialog.ScrollbarTrack = base.Foreground(border)
 
 	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
 	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")