From 8303ee1dc51119631d979a09f6ac2540e3479019 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 26 Feb 2026 01:12:44 +0300 Subject: [PATCH] feat(ui): add ApprovalComponent as parent-managed Bubble Tea child model Refactors tool approval from the standalone ToolApprovalInput (which called tea.Quit) into ApprovalComponent that returns approvalResultMsg{Approved: bool} via a tea.Cmd, letting AppModel own the program lifecycle. Wires the ToolApprovalNeededEvent handler in model.go to construct and display the component. --- internal/ui/approval.go | 133 ++++++++++++++++++++++++++++++++++++++++ internal/ui/model.go | 5 +- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 internal/ui/approval.go diff --git a/internal/ui/approval.go b/internal/ui/approval.go new file mode 100644 index 00000000..d5e2ddc0 --- /dev/null +++ b/internal/ui/approval.go @@ -0,0 +1,133 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// ApprovalComponent is the tool approval dialog for the parent AppModel. +// It displays tool name and arguments, lets the user approve or deny the call, +// and returns an approvalResultMsg tea.Cmd instead of tea.Quit — lifecycle is +// entirely managed by the parent. +// +// Key bindings: +// - y / Y → approve immediately +// - n / N → deny immediately +// - left → select "yes" +// - right → select "no" +// - enter → confirm current selection +// - esc / ctrl+c → deny (same as "no") +type ApprovalComponent struct { + toolName string + toolArgs string + width int + selected bool // true = "yes" highlighted, false = "no" highlighted +} + +// NewApprovalComponent creates a new ApprovalComponent for the given tool call. +// width is the terminal width passed down from the parent model. +// By default the "yes" option is highlighted. +func NewApprovalComponent(toolName, toolArgs string, width int) *ApprovalComponent { + return &ApprovalComponent{ + toolName: toolName, + toolArgs: toolArgs, + width: width, + selected: true, // default to "yes" + } +} + +// Init implements tea.Model. No startup commands needed. +func (a *ApprovalComponent) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. Handles keyboard input and returns an +// approvalResultMsg tea.Cmd when the user makes a decision. +// It does NOT return tea.Quit — the parent owns the program lifecycle. +func (a *ApprovalComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "y", "Y": + return a, approvalResult(true) + case "n", "N": + return a, approvalResult(false) + case "left": + a.selected = true + return a, nil + case "right": + a.selected = false + return a, nil + case "enter": + return a, approvalResult(a.selected) + case "esc", "ctrl+c": + return a, approvalResult(false) + } + case tea.WindowSizeMsg: + a.width = msg.Width + } + return a, nil +} + +// View implements tea.Model. Renders the approval dialog with tool info and +// yes/no selection. +func (a *ApprovalComponent) View() tea.View { + // Add left padding to entire component (2 spaces like other UI elements). + containerStyle := lipgloss.NewStyle().PaddingLeft(2) + + // Title + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + MarginBottom(1) + + // Input box with huh-like styling + inputBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderLeft(true). + BorderRight(false). + BorderTop(false). + BorderBottom(false). + BorderForeground(lipgloss.Color("39")). + PaddingLeft(1). + Width(a.width - 2) // Account for container padding + + // Style for the currently selected option + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). // Bright green + Bold(true). + Underline(true) + + // Style for the unselected option + unselectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) // Dark gray + + var view strings.Builder + view.WriteString(titleStyle.Render("Allow tool execution")) + view.WriteString("\n") + view.WriteString(fmt.Sprintf("Tool: %s\nArguments: %s\n\n", a.toolName, a.toolArgs)) + view.WriteString("Allow tool execution: ") + + var yesText, noText string + if a.selected { + yesText = selectedStyle.Render("[y]es") + noText = unselectedStyle.Render("[n]o") + } else { + yesText = unselectedStyle.Render("[y]es") + noText = selectedStyle.Render("[n]o") + } + view.WriteString(yesText + "/" + noText + "\n") + + return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String()))) +} + +// approvalResult returns a tea.Cmd that emits an approvalResultMsg with the +// given decision. The parent AppModel receives this and sends the result on +// the stored approvalChan. +func approvalResult(approved bool) tea.Cmd { + return func() tea.Msg { + return approvalResultMsg{Approved: approved} + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 6b1085ae..89c08a8b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -346,7 +346,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Store the response channel and transition to approval state. m.approvalChan = msg.ResponseChan m.state = stateApproval - // TODO (TAS-17): construct ApprovalComponent with msg.ToolName/ToolArgs. + // Construct the ApprovalComponent and init it (returns nil cmd, but good practice). + approvalComp := NewApprovalComponent(msg.ToolName, msg.ToolArgs, m.width) + cmds = append(cmds, approvalComp.Init()) + m.approval = approvalComp case app.StepCompleteEvent: // Emit the completed response above the BT region via tea.Println,