wip: tui permissions

This commit is contained in:
adamdotdevin
2025-07-31 09:34:43 -05:00
parent e7631763f3
commit 5500698734
26 changed files with 1448 additions and 179 deletions

View File

@@ -26,26 +26,28 @@ type Message struct {
}
type App struct {
Info opencode.App
Modes []opencode.Mode
Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []Message
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
IsLeaderSequence bool
Info opencode.App
Modes []opencode.Mode
Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []Message
Permissions []opencode.Permission
CurrentPermission opencode.Permission
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
type SessionCreatedMsg = struct {

View File

@@ -344,9 +344,13 @@ func (m *editorComponent) Content() string {
hint = base(keyText+" again") + muted(" to exit")
} else if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
status := "working"
if m.app.CurrentPermission.ID != "" {
status = "waiting for permission"
}
if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
hint = muted(
"working",
status,
) + m.spinner.View() + muted(
" ",
) + base(
@@ -355,7 +359,10 @@ func (m *editorComponent) Content() string {
" interrupt",
)
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
hint = muted(status) + m.spinner.View()
if m.app.CurrentPermission.ID == "" {
hint += muted(" ") + base(keyText) + muted(" interrupt")
}
}
}

View File

@@ -3,6 +3,7 @@ package chat
import (
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
"time"
@@ -22,16 +23,17 @@ import (
)
type blockRenderer struct {
textColor compat.AdaptiveColor
border bool
borderColor *compat.AdaptiveColor
borderColorRight bool
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
textColor compat.AdaptiveColor
border bool
borderColor *compat.AdaptiveColor
borderLeft bool
borderRight bool
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
}
type renderingOption func(*blockRenderer)
@@ -54,10 +56,26 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
}
}
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
func WithBorderLeft() renderingOption {
return func(c *blockRenderer) {
c.borderColorRight = true
c.borderColor = &color
c.borderLeft = true
c.borderRight = false
}
}
func WithBorderRight() renderingOption {
return func(c *blockRenderer) {
c.borderLeft = false
c.borderRight = true
}
}
func WithBorderBoth(value bool) renderingOption {
return func(c *blockRenderer) {
if value {
c.borderLeft = true
c.borderRight = true
}
}
}
@@ -116,6 +134,8 @@ func renderContentBlock(
renderer := &blockRenderer{
textColor: t.TextMuted(),
border: true,
borderLeft: true,
borderRight: false,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
@@ -144,19 +164,17 @@ func renderContentBlock(
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(borderColor).
BorderLeftForeground(t.BackgroundPanel()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background())
if renderer.borderColorRight {
style = style.
BorderLeftBackground(t.Background()).
BorderLeftForeground(t.BackgroundPanel()).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background())
if renderer.borderLeft {
style = style.BorderLeftForeground(borderColor)
}
if renderer.borderRight {
style = style.BorderRightForeground(borderColor)
}
}
content = style.Render(content)
@@ -223,7 +241,7 @@ func renderText(
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, width)
title := renderToolTitle(toolCall, width-2)
style := styles.NewStyle()
if toolCall.State.Status == opencode.ToolPartStateStatusError {
style = style.Foreground(t.Error())
@@ -247,7 +265,8 @@ func renderText(
content,
width,
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
WithBorderColor(t.Secondary()),
WithBorderRight(),
)
case opencode.AssistantMessage:
return renderContentBlock(
@@ -263,6 +282,7 @@ func renderText(
func renderToolDetails(
app *app.App,
toolCall opencode.ToolPart,
permission opencode.Permission,
width int,
) string {
measure := util.Measure("chat.renderToolDetails")
@@ -301,6 +321,39 @@ func renderToolDetails(
borderColor := t.BackgroundPanel()
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
permissionContent := ""
if permission.ID != "" {
borderColor = t.Warning()
base := styles.NewStyle().Background(backgroundColor)
text := base.Foreground(t.Text()).Bold(true).Render
muted := base.Foreground(t.TextMuted()).Render
permissionContent = "Permission required to run this tool:\n\n"
permissionContent += text(
"enter ",
) + muted(
"accept ",
) + text(
"a",
) + muted(
" accept always ",
) + text(
"esc",
) + muted(
" reject",
)
}
if permission.Metadata != nil {
metadata := toolCall.State.Metadata.(map[string]any)
if metadata == nil {
metadata = map[string]any{}
}
maps.Copy(metadata, permission.Metadata)
toolCall.State.Metadata = metadata
}
if toolCall.State.Metadata != nil {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
@@ -351,12 +404,20 @@ func renderToolDetails(
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
if permissionContent != "" {
permissionContent = styles.NewStyle().
Background(backgroundColor).
Padding(1, 2).
Render(permissionContent)
content += "\n" + permissionContent
}
content = renderContentBlock(
app,
content,
width,
WithPadding(0),
WithBorderColor(borderColor),
WithBorderBoth(permission.ID != ""),
)
return content
}
@@ -417,7 +478,7 @@ func renderToolDetails(
data, _ := json.Marshal(item)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step := renderToolTitle(toolCall, width-2)
step = "∟ " + step
steps = append(steps, step)
}
@@ -460,7 +521,18 @@ func renderToolDetails(
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, width, WithBorderColor(borderColor))
if permissionContent != "" {
content += "\n\n\n" + permissionContent
}
return renderContentBlock(
app,
content,
width,
WithBorderColor(borderColor),
WithBorderBoth(permission.ID != ""),
)
}
func renderToolName(name string) string {
@@ -575,6 +647,10 @@ func renderToolTitle(
}
title = truncate.StringWithTail(title, uint(width-6), "...")
if toolCall.State.Error != "" {
t := theme.CurrentTheme()
title = styles.NewStyle().Foreground(t.Error()).Render(title)
}
return title
}

View File

@@ -100,8 +100,6 @@ func (m *messagesComponent) Init() tea.Cmd {
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
measure := util.Measure("messages.Update")
defer measure("from", fmt.Sprintf("%T", msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.MouseClickMsg:
@@ -199,6 +197,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cache.Clear()
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventPermissionUpdated:
m.tail = true
return m, m.renderView()
case renderCompleteMsg:
m.partCount = msg.partCount
m.lineCount = msg.lineCount
@@ -214,6 +215,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.tail = m.viewport.AtBottom()
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
cmds = append(cmds, cmd)
@@ -465,7 +467,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
revertedToolCount++
continue
}
if !m.showToolDetails {
permission := opencode.Permission{}
if m.app.CurrentPermission.ToolCallID == part.CallID {
permission = m.app.CurrentPermission
}
if !m.showToolDetails && permission.ID == "" {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
}
@@ -477,12 +485,14 @@ func (m *messagesComponent) renderView() tea.Cmd {
part.ID,
m.showToolDetails,
width,
permission.ID,
)
content, cached = m.cache.Get(key)
if !cached {
content = renderToolDetails(
m.app,
part,
permission,
width,
)
content = lipgloss.PlaceHorizontal(
@@ -498,6 +508,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
content = renderToolDetails(
m.app,
part,
permission,
width,
)
content = lipgloss.PlaceHorizontal(
@@ -618,6 +629,40 @@ func (m *messagesComponent) renderView() tea.Cmd {
blocks = append(blocks, content)
}
if m.app.CurrentPermission.ID != "" &&
m.app.CurrentPermission.SessionID != m.app.Session.ID {
response, err := m.app.Client.Session.Message(
context.Background(),
m.app.CurrentPermission.SessionID,
m.app.CurrentPermission.MessageID,
)
if err != nil || response == nil {
slog.Error("Failed to get message from child session", "error", err)
} else {
for _, part := range response.Parts {
if part.CallID == m.app.CurrentPermission.ToolCallID {
content := renderToolDetails(
m.app,
part.AsUnion().(opencode.ToolPart),
m.app.CurrentPermission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
}
}
final := []string{}
clipboard := []string{}
var selection *selection
@@ -846,9 +891,7 @@ func (m *messagesComponent) View() string {
)
}
measure := util.Measure("messages.View")
viewport := m.viewport.View()
measure()
return styles.NewStyle().
Background(t.Background()).
Render(m.header + "\n" + viewport)

View File

@@ -138,8 +138,6 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
}
case "n":
s.app.Session = &opencode.Session{}
s.app.Messages = []app.Message{}
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionClearedMsg{}),

View File

@@ -103,9 +103,6 @@ func (a Model) Init() tea.Cmd {
}
func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
measure := util.Measure("app.Update")
defer measure("from", fmt.Sprintf("%T", msg))
var cmd tea.Cmd
var cmds []tea.Cmd
@@ -113,6 +110,45 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
keyString := msg.String()
if a.app.CurrentPermission.ID != "" {
if keyString == "enter" || keyString == "esc" || keyString == "a" {
sessionID := a.app.CurrentPermission.SessionID
permissionID := a.app.CurrentPermission.ID
a.editor.Focus()
a.app.Permissions = a.app.Permissions[1:]
if len(a.app.Permissions) > 0 {
a.app.CurrentPermission = a.app.Permissions[0]
} else {
a.app.CurrentPermission = opencode.Permission{}
}
response := opencode.SessionPermissionRespondParamsResponseOnce
switch keyString {
case "enter":
response = opencode.SessionPermissionRespondParamsResponseOnce
case "a":
response = opencode.SessionPermissionRespondParamsResponseAlways
case "esc":
response = opencode.SessionPermissionRespondParamsResponseReject
}
return a, func() tea.Msg {
resp, err := a.app.Client.Session.Permissions.Respond(
context.Background(),
sessionID,
permissionID,
opencode.SessionPermissionRespondParams{Response: opencode.F(response)},
)
if err != nil {
slog.Error("Failed to respond to permission request", "error", err)
return toast.NewErrorToast("Failed to respond to permission request")
}
slog.Debug("Responded to permission request", "response", resp)
return nil
}
}
}
// 1. Handle active modal
if a.modal != nil {
switch keyString {
@@ -341,6 +377,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := a.editor.Focus()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case app.SessionClearedMsg:
a.app.Session = &opencode.Session{}
a.app.Messages = []app.Message{}
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case opencode.EventListResponseEventInstallationUpdated:
@@ -364,7 +403,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Session = &msg.Properties.Info
}
case opencode.EventListResponseEventMessagePartUpdated:
slog.Info("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
if msg.Properties.Part.SessionID == a.app.Session.ID {
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
switch casted := m.Info.(type) {
@@ -402,7 +441,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case opencode.EventListResponseEventMessagePartRemoved:
slog.Info("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
if msg.Properties.SessionID == a.app.Session.ID {
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
switch casted := m.Info.(type) {
@@ -438,7 +477,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case opencode.EventListResponseEventMessageRemoved:
slog.Info("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
if msg.Properties.SessionID == a.app.Session.ID {
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
switch casted := m.Info.(type) {
@@ -480,6 +519,12 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
})
}
}
case opencode.EventListResponseEventPermissionUpdated:
slog.Debug("permission updated", "session", msg.Properties.SessionID, "permission", msg.Properties.ID)
a.app.Permissions = append(a.app.Permissions, msg.Properties)
a.app.CurrentPermission = a.app.Permissions[0]
cmds = append(cmds, toast.NewInfoToast(msg.Properties.Title, toast.WithTitle("Permission requested")))
a.editor.Blur()
case opencode.EventListResponseEventSessionError:
switch err := msg.Properties.Error.AsUnion().(type) {
case nil:
@@ -613,8 +658,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a Model) View() string {
measure := util.Measure("app.View")
defer measure()
t := theme.CurrentTheme()
var mainLayout string
@@ -674,8 +717,6 @@ func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) {
}
func (a Model) home() string {
measure := util.Measure("home.View")
defer measure()
t := theme.CurrentTheme()
effectiveWidth := a.width - 4
baseStyle := styles.NewStyle().Background(t.Background())
@@ -796,8 +837,6 @@ func (a Model) home() string {
}
func (a Model) chat() string {
measure := util.Measure("chat.View")
defer measure()
effectiveWidth := a.width - 4
t := theme.CurrentTheme()
editorView := a.editor.View()
@@ -911,9 +950,8 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
if a.app.Session.ID == "" {
return a, nil
}
a.app.Session = &opencode.Session{}
a.app.Messages = []app.Message{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog