summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-31 09:34:43 -0500
committeradamdotdevin <[email protected]>2025-07-31 09:59:17 -0500
commit5500698734c5119ccd7b0d9ab504bb3a53e5e39d (patch)
tree6e77b461a2a488aab213355a5fb47c7b304bce78 /packages/tui/internal
parente7631763f3e42292d3cecf14c0ebf295d9b9b333 (diff)
downloadopencode-5500698734c5119ccd7b0d9ab504bb3a53e5e39d.tar.gz
opencode-5500698734c5119ccd7b0d9ab504bb3a53e5e39d.zip
wip: tui permissions
Diffstat (limited to 'packages/tui/internal')
-rw-r--r--packages/tui/internal/app/app.go42
-rw-r--r--packages/tui/internal/components/chat/editor.go13
-rw-r--r--packages/tui/internal/components/chat/message.go126
-rw-r--r--packages/tui/internal/components/chat/messages.go53
-rw-r--r--packages/tui/internal/components/dialog/session.go2
-rw-r--r--packages/tui/internal/tui/tui.go66
6 files changed, 233 insertions, 69 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index df4e209cf..672bc0ee6 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -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 {
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 009a7ab6c..677d903fd 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -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")
+ }
}
}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 5c92dee5c..10e1b0692 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -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
}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index e675a35d2..96ea8241d 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -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)
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index 307897bc5..daf7a142b 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/packages/tui/internal/components/dialog/session.go
@@ -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{}),
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 76b96a8e4..9b6ec7ea6 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -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