summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/chat/chat.go2
-rw-r--r--internal/tui/components/chat/editor.go22
-rw-r--r--internal/tui/components/chat/messages.go205
-rw-r--r--internal/tui/components/chat/sidebar.go176
-rw-r--r--internal/tui/components/core/dialog.go117
-rw-r--r--internal/tui/components/core/help.go119
-rw-r--r--internal/tui/components/core/status.go90
-rw-r--r--internal/tui/components/dialog/help.go182
-rw-r--r--internal/tui/components/dialog/permission.go682
-rw-r--r--internal/tui/components/dialog/quit.go156
-rw-r--r--internal/tui/components/logs/details.go2
-rw-r--r--internal/tui/components/logs/table.go22
-rw-r--r--internal/tui/components/repl/editor.go201
-rw-r--r--internal/tui/components/repl/messages.go513
-rw-r--r--internal/tui/components/repl/sessions.go249
15 files changed, 1036 insertions, 1702 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index e893ec2f5..e98001efa 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
-type AgentWorkingMsg bool
-
type EditorFocusMsg bool
func lspsConfigured(width int) string {
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index e87f1ffae..e2f4da9e2 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -5,14 +5,17 @@ import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/app"
+ "github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
)
type editorCmp struct {
- textarea textarea.Model
- agentWorking bool
+ app *app.App
+ session session.Session
+ textarea textarea.Model
}
type focusedEditorKeyMaps struct {
@@ -32,7 +35,7 @@ var focusedKeyMaps = focusedEditorKeyMaps{
),
Blur: key.NewBinding(
key.WithKeys("esc"),
- key.WithHelp("esc", "blur editor"),
+ key.WithHelp("esc", "focus messages"),
),
}
@@ -52,7 +55,7 @@ func (m *editorCmp) Init() tea.Cmd {
}
func (m *editorCmp) send() tea.Cmd {
- if m.agentWorking {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return util.ReportWarn("Agent is working, please wait...")
}
@@ -66,7 +69,6 @@ func (m *editorCmp) send() tea.Cmd {
util.CmdHandler(SendMsg{
Text: value,
}),
- util.CmdHandler(AgentWorkingMsg(true)),
util.CmdHandler(EditorFocusMsg(false)),
)
}
@@ -74,8 +76,11 @@ func (m *editorCmp) send() tea.Cmd {
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
- case AgentWorkingMsg:
- m.agentWorking = bool(msg)
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ }
+ return m, nil
case tea.KeyMsg:
// if the key does not match any binding, return
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
@@ -122,7 +127,7 @@ func (m *editorCmp) BindingKeys() []key.Binding {
return bindings
}
-func NewEditorCmp() tea.Model {
+func NewEditorCmp(app *app.App) tea.Model {
ti := textarea.New()
ti.Prompt = " "
ti.ShowLineNumbers = false
@@ -138,6 +143,7 @@ func NewEditorCmp() tea.Model {
ti.CharLimit = -1
ti.Focus()
return &editorCmp{
+ app: app,
textarea: ti,
}
}
diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go
index dc21fca29..26a98970e 100644
--- a/internal/tui/components/chat/messages.go
+++ b/internal/tui/components/chat/messages.go
@@ -6,7 +6,9 @@ import (
"fmt"
"math"
"strings"
+ "time"
+ "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -17,9 +19,11 @@ import (
"github.com/kujtimiihoxha/termai/internal/llm/agent"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/llm/tools"
+ "github.com/kujtimiihoxha/termai/internal/logging"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
)
@@ -32,6 +36,9 @@ const (
toolMessageType
)
+// messagesTickMsg is a message sent by the timer to refresh messages
+type messagesTickMsg time.Time
+
type uiMessage struct {
ID string
messageType uiMessageType
@@ -52,24 +59,34 @@ type messagesCmp struct {
renderer *glamour.TermRenderer
focusRenderer *glamour.TermRenderer
cachedContent map[string]string
- agentWorking bool
spinner spinner.Model
needsRerender bool
- lastViewport string
}
func (m *messagesCmp) Init() tea.Cmd {
- return tea.Batch(m.viewport.Init())
+ return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
+}
+
+func (m *messagesCmp) tickMessages() tea.Cmd {
+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return messagesTickMsg(t)
+ })
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case AgentWorkingMsg:
- m.agentWorking = bool(msg)
- if m.agentWorking {
- cmds = append(cmds, m.spinner.Tick)
+ case messagesTickMsg:
+ // Refresh messages if we have an active session
+ if m.session.ID != "" {
+ messages, err := m.app.Messages.List(context.Background(), m.session.ID)
+ if err == nil {
+ m.messages = messages
+ m.needsRerender = true
+ }
}
+ // Continue ticking
+ cmds = append(cmds, m.tickMessages())
case EditorFocusMsg:
m.writingMode = bool(msg)
case SessionSelectedMsg:
@@ -84,6 +101,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.messages = make([]message.Message, 0)
m.currentMsgID = ""
m.needsRerender = true
+ m.cachedContent = make(map[string]string)
return m, nil
case tea.KeyMsg:
@@ -104,6 +122,12 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if !messageExists {
+ // If we have messages, ensure the previous last message is not cached
+ if len(m.messages) > 0 {
+ lastMsgID := m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, lastMsgID)
+ }
+
m.messages = append(m.messages, msg.Payload)
delete(m.cachedContent, m.currentMsgID)
m.currentMsgID = msg.Payload.ID
@@ -112,36 +136,40 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
for _, v := range m.messages {
for _, c := range v.ToolCalls() {
- // the message is being added to the session of a tool called
if c.ID == msg.Payload.SessionID {
m.needsRerender = true
}
}
}
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
+ logging.Debug("Message", "finish", msg.Payload.FinishReason())
for i, v := range m.messages {
if v.ID == msg.Payload.ID {
- if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
- cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
- }
m.messages[i] = msg.Payload
delete(m.cachedContent, msg.Payload.ID)
+
+ // If this is the last message, ensure it's not cached
+ if i == len(m.messages)-1 {
+ delete(m.cachedContent, msg.Payload.ID)
+ }
+
m.needsRerender = true
break
}
}
}
}
- if m.agentWorking {
- u, cmd := m.spinner.Update(msg)
- m.spinner = u
- cmds = append(cmds, cmd)
- }
+
oldPos := m.viewport.YPosition
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
cmds = append(cmds, cmd)
+
+ spinner, cmd := m.spinner.Update(msg)
+ m.spinner = spinner
+ cmds = append(cmds, cmd)
+
if m.needsRerender {
m.renderView()
if len(m.messages) > 0 {
@@ -157,10 +185,21 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
+func (m *messagesCmp) IsAgentWorking() bool {
+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
+}
+
func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
- if v, ok := m.cachedContent[msg.ID]; ok {
- return v
+ // Check if this is the last message in the list
+ isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
+
+ // Only use cache for non-last messages
+ if !isLastMessage {
+ if v, ok := m.cachedContent[msg.ID]; ok {
+ return v
+ }
}
+
style := styles.BaseStyle.
Width(m.width).
BorderLeft(true).
@@ -191,7 +230,12 @@ func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) s
parts...,
),
)
- m.cachedContent[msg.ID] = rendered
+
+ // Only cache if it's not the last message
+ if !isLastMessage {
+ m.cachedContent[msg.ID] = rendered
+ }
+
return rendered
}
@@ -207,32 +251,71 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
+func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
+ for _, v := range m.messages {
+ for _, c := range v.ToolResults() {
+ if c.ToolCallID == callID {
+ return &c
+ }
+ }
+ }
+ return nil
+}
+
func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
key := ""
value := ""
+ result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
+
+ response := m.findToolResponse(toolCall.ID)
+ if response != nil && response.IsError {
+ // Clean up error message for display by removing newlines
+ // This ensures error messages display properly in the UI
+ errMsg := strings.ReplaceAll(response.Content, "\n", " ")
+ result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
+ } else if response != nil {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
+ }
switch toolCall.Name {
// TODO: add result data to the tools
case agent.AgentToolName:
key = "Task"
var params agent.AgentParams
json.Unmarshal([]byte(toolCall.Input), &params)
- value = params.Prompt
- // TODO: handle nested calls
+ value = strings.ReplaceAll(params.Prompt, "\n", " ")
+ if response != nil && !response.IsError {
+ firstRow := strings.ReplaceAll(response.Content, "\n", " ")
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
+ }
case tools.BashToolName:
key = "Bash"
var params tools.BashParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Command
+ if response != nil && !response.IsError {
+ metadata := tools.BashResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
+ }
+
case tools.EditToolName:
key = "Edit"
var params tools.EditParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
+ if response != nil && !response.IsError {
+ metadata := tools.EditResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
+ }
case tools.FetchToolName:
key = "Fetch"
var params tools.FetchParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.URL
+ if response != nil && !response.IsError {
+ result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
+ }
case tools.GlobToolName:
key = "Glob"
var params tools.GlobParams
@@ -241,6 +324,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
+ if response != nil && !response.IsError {
+ metadata := tools.GlobResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ if metadata.Truncated {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
+ } else {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
+ }
+ }
case tools.GrepToolName:
key = "Grep"
var params tools.GrepParams
@@ -249,19 +341,46 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
+ if response != nil && !response.IsError {
+ metadata := tools.GrepResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ if metadata.Truncated {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
+ } else {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
+ }
+ }
case tools.LSToolName:
- key = "Ls"
+ key = "ls"
var params tools.LSParams
json.Unmarshal([]byte(toolCall.Input), &params)
if params.Path == "" {
params.Path = "."
}
value = params.Path
+ if response != nil && !response.IsError {
+ metadata := tools.LSResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ if metadata.Truncated {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
+ } else {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
+ }
+ }
case tools.SourcegraphToolName:
key = "Sourcegraph"
var params tools.SourcegraphParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Query
+ if response != nil && !response.IsError {
+ metadata := tools.SourcegraphResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ if metadata.Truncated {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
+ } else {
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
+ }
+ }
case tools.ViewToolName:
key = "View"
var params tools.ViewParams
@@ -272,6 +391,12 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
var params tools.WriteParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
+ if response != nil && !response.IsError {
+ metadata := tools.WriteResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+
+ result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
+ }
default:
key = toolCall.Name
var params map[string]any
@@ -300,14 +425,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
)
if !isNested {
value = valyeStyle.
- Width(m.width - lipgloss.Width(keyValye) - 2).
Render(
ansi.Truncate(
- value,
- m.width-lipgloss.Width(keyValye)-2,
+ value+" ",
+ m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
"...",
),
)
+ value += result
+
} else {
keyValye = keyStyle.Render(
fmt.Sprintf(" └ %s: ", key),
@@ -409,6 +535,27 @@ func (m *messagesCmp) renderView() {
m.uiMessages = make([]uiMessage, 0)
pos := 0
+ // If we have messages, ensure the last message is not cached
+ // This ensures we always render the latest content for the most recent message
+ // which may be actively updating (e.g., during generation)
+ if len(m.messages) > 0 {
+ lastMsgID := m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, lastMsgID)
+ }
+
+ // Limit cache to 10 messages
+ if len(m.cachedContent) > 15 {
+ // Create a list of keys to delete (oldest messages first)
+ keys := make([]string, 0, len(m.cachedContent))
+ for k := range m.cachedContent {
+ keys = append(keys, k)
+ }
+ // Delete oldest messages until we have 10 or fewer
+ for i := 0; i < len(keys)-15; i++ {
+ delete(m.cachedContent, keys[i])
+ }
+ }
+
for _, v := range m.messages {
switch v.Role {
case message.User:
@@ -487,7 +634,7 @@ func (m *messagesCmp) View() string {
func (m *messagesCmp) help() string {
text := ""
- if m.agentWorking {
+ if m.IsAgentWorking() {
text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
)
@@ -562,9 +709,15 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
m.messages = messages
m.currentMsgID = m.messages[len(m.messages)-1].ID
m.needsRerender = true
+ m.cachedContent = make(map[string]string)
return nil
}
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
+ return bindings
+}
+
func NewMessagesCmp(app *app.App) tea.Model {
focusRenderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index 51192cf9a..b90269d1a 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -1,10 +1,15 @@
package chat
import (
+ "context"
"fmt"
+ "strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/config"
+ "github.com/kujtimiihoxha/termai/internal/diff"
+ "github.com/kujtimiihoxha/termai/internal/history"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
@@ -13,9 +18,33 @@ import (
type sidebarCmp struct {
width, height int
session session.Session
+ history history.Service
+ modFiles map[string]struct {
+ additions int
+ removals int
+ }
}
func (m *sidebarCmp) Init() tea.Cmd {
+ if m.history != nil {
+ ctx := context.Background()
+ // Subscribe to file events
+ filesCh := m.history.Subscribe(ctx)
+
+ // Initialize the modified files map
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+
+ // Load initial files and calculate diffs
+ m.loadModifiedFiles(ctx)
+
+ // Return a command that will send file events to the Update method
+ return func() tea.Msg {
+ return <-filesCh
+ }
+ }
return nil
}
@@ -27,6 +56,13 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session = msg.Payload
}
}
+ case pubsub.Event[history.File]:
+ if msg.Payload.SessionID == m.session.ID {
+ // When a file changes, reload all modified files
+ // This ensures we have the complete and accurate list
+ ctx := context.Background()
+ m.loadModifiedFiles(ctx)
+ }
}
return m, nil
}
@@ -86,18 +122,28 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
func (m *sidebarCmp) modifiedFiles() string {
modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
- files := []struct {
- path string
- additions int
- removals int
- }{
- {"file1.txt", 10, 5},
- {"file2.txt", 20, 0},
- {"file3.txt", 0, 15},
+
+ // If no modified files, show a placeholder message
+ if m.modFiles == nil || len(m.modFiles) == 0 {
+ message := "No modified files"
+ remainingWidth := m.width - lipgloss.Width(modifiedFiles)
+ if remainingWidth > 0 {
+ message += strings.Repeat(" ", remainingWidth)
+ }
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
+ ),
+ )
}
+
var fileViews []string
- for _, file := range files {
- fileViews = append(fileViews, m.modifiedFile(file.path, file.additions, file.removals))
+ for path, stats := range m.modFiles {
+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return styles.BaseStyle.
@@ -123,8 +169,116 @@ func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
-func NewSidebarCmp(session session.Session) tea.Model {
+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
return &sidebarCmp{
session: session,
+ history: history,
+ }
+}
+
+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
+ if m.history == nil || m.session.ID == "" {
+ return
+ }
+
+ // Get all latest files for this session
+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Get all files for this session (to find initial versions)
+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Process each latest file
+ for _, file := range latestFiles {
+ // Skip if this is the initial version (no changes to show)
+ if file.Version == history.InitialVersion {
+ continue
+ }
+
+ // Find the initial version for this specific file
+ var initialVersion history.File
+ for _, v := range allFiles {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ continue
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ // Remove working directory prefix from file path
+ displayPath := file.Path
+ workingDir := config.WorkingDirectory()
+ displayPath = strings.TrimPrefix(displayPath, workingDir)
+ displayPath = strings.TrimPrefix(displayPath, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
+ }
+ }
+}
+
+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
+ // Skip if not the latest version
+ if file.Version == history.InitialVersion {
+ return
+ }
+
+ // Get all versions of this file
+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Find the initial version
+ var initialVersion history.File
+ for _, v := range fileVersions {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ return
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ // Remove working directory prefix from file path
+ displayPath := file.Path
+ workingDir := config.WorkingDirectory()
+ displayPath = strings.TrimPrefix(displayPath, workingDir)
+ displayPath = strings.TrimPrefix(displayPath, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
}
}
diff --git a/internal/tui/components/core/dialog.go b/internal/tui/components/core/dialog.go
deleted file mode 100644
index a8fef2e86..000000000
--- a/internal/tui/components/core/dialog.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package core
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-)
-
-type SizeableModel interface {
- tea.Model
- layout.Sizeable
-}
-
-type DialogMsg struct {
- Content SizeableModel
- WidthRatio float64
- HeightRatio float64
-
- MinWidth int
- MinHeight int
-}
-
-type DialogCloseMsg struct{}
-
-type KeyBindings struct {
- Return key.Binding
-}
-
-var keys = KeyBindings{
- Return: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-type DialogCmp interface {
- tea.Model
- layout.Bindings
-}
-
-type dialogCmp struct {
- content SizeableModel
- screenWidth int
- screenHeight int
-
- widthRatio float64
- heightRatio float64
-
- minWidth int
- minHeight int
-
- width int
- height int
-}
-
-func (d *dialogCmp) Init() tea.Cmd {
- return nil
-}
-
-func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- d.screenWidth = msg.Width
- d.screenHeight = msg.Height
- d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
- d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
- if d.content != nil {
- d.content.SetSize(d.width, d.height)
- }
- return d, nil
- case DialogMsg:
- d.content = msg.Content
- d.widthRatio = msg.WidthRatio
- d.heightRatio = msg.HeightRatio
- d.minWidth = msg.MinWidth
- d.minHeight = msg.MinHeight
- d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
- d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
- if d.content != nil {
- d.content.SetSize(d.width, d.height)
- }
- case DialogCloseMsg:
- d.content = nil
- return d, nil
- case tea.KeyMsg:
- if key.Matches(msg, keys.Return) {
- return d, util.CmdHandler(DialogCloseMsg{})
- }
- }
- if d.content != nil {
- u, cmd := d.content.Update(msg)
- d.content = u.(SizeableModel)
- return d, cmd
- }
- return d, nil
-}
-
-func (d *dialogCmp) BindingKeys() []key.Binding {
- bindings := []key.Binding{keys.Return}
- if d.content == nil {
- return bindings
- }
- if c, ok := d.content.(layout.Bindings); ok {
- return append(bindings, c.BindingKeys()...)
- }
- return bindings
-}
-
-func (d *dialogCmp) View() string {
- return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View())
-}
-
-func NewDialogCmp() DialogCmp {
- return &dialogCmp{}
-}
diff --git a/internal/tui/components/core/help.go b/internal/tui/components/core/help.go
deleted file mode 100644
index 4ef857c78..000000000
--- a/internal/tui/components/core/help.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package core
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-type HelpCmp interface {
- tea.Model
- SetBindings(bindings []key.Binding)
- Height() int
-}
-
-const (
- helpWidgetHeight = 12
-)
-
-type helpCmp struct {
- width int
- bindings []key.Binding
-}
-
-func (h *helpCmp) Init() tea.Cmd {
- return nil
-}
-
-func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = msg.Width
- }
- return h, nil
-}
-
-func (h *helpCmp) View() string {
- helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
- helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
- // Compile list of bindings to render
- bindings := removeDuplicateBindings(h.bindings)
- // Enumerate through each group of bindings, populating a series of
- // pairs of columns, one for keys, one for descriptions
- var (
- pairs []string
- width int
- rows = helpWidgetHeight - 2
- )
- for i := 0; i < len(bindings); i += rows {
- var (
- keys []string
- descs []string
- )
- for j := i; j < min(i+rows, len(bindings)); j++ {
- keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
- descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
- }
- // Render pair of columns; beyond the first pair, render a three space
- // left margin, in order to visually separate the pairs.
- var cols []string
- if len(pairs) > 0 {
- cols = []string{" "}
- }
- cols = append(cols,
- strings.Join(keys, "\n"),
- strings.Join(descs, "\n"),
- )
-
- pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
- // check whether it exceeds the maximum width avail (the width of the
- // terminal, subtracting 2 for the borders).
- width += lipgloss.Width(pair)
- if width > h.width-2 {
- break
- }
- pairs = append(pairs, pair)
- }
-
- // Join pairs of columns and enclose in a border
- content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
- return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
-}
-
-func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
- seen := make(map[string]struct{})
- result := make([]key.Binding, 0, len(bindings))
-
- // Process bindings in reverse order
- for i := len(bindings) - 1; i >= 0; i-- {
- b := bindings[i]
- k := strings.Join(b.Keys(), " ")
- if _, ok := seen[k]; ok {
- // duplicate, skip
- continue
- }
- seen[k] = struct{}{}
- // Add to the beginning of result to maintain original order
- result = append([]key.Binding{b}, result...)
- }
-
- return result
-}
-
-func (h *helpCmp) SetBindings(bindings []key.Binding) {
- h.bindings = bindings
-}
-
-func (h helpCmp) Height() int {
- return helpWidgetHeight
-}
-
-func NewHelpCmp() HelpCmp {
- return &helpCmp{
- width: 0,
- bindings: make([]key.Binding, 0),
- }
-}
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
index 93ba34507..089dffa2c 100644
--- a/internal/tui/components/core/status.go
+++ b/internal/tui/components/core/status.go
@@ -1,21 +1,25 @@
package core
import (
+ "fmt"
+ "strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/llm/models"
+ "github.com/kujtimiihoxha/termai/internal/lsp"
+ "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/kujtimiihoxha/termai/internal/version"
)
type statusCmp struct {
info util.InfoMsg
width int
messageTTL time.Duration
+ lspClients map[string]*lsp.Client
}
// clearMessageCmd is a command that clears status messages after a timeout
@@ -47,20 +51,18 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-var (
- versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
- helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
-)
+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
func (m statusCmp) View() string {
- status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
+ status := helpWidget
+ diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
if m.info.Msg != "" {
infoStyle := styles.Padded.
Foreground(styles.Base).
- Width(m.availableFooterMsgWidth())
+ Width(m.availableFooterMsgWidth(diagnostics))
switch m.info.Type {
case util.InfoTypeInfo:
- infoStyle = infoStyle.Background(styles.Blue)
+ infoStyle = infoStyle.Background(styles.BorderColor)
case util.InfoTypeWarn:
infoStyle = infoStyle.Background(styles.Peach)
case util.InfoTypeError:
@@ -68,7 +70,7 @@ func (m statusCmp) View() string {
}
// Truncate message if it's longer than available width
msg := m.info.Msg
- availWidth := m.availableFooterMsgWidth() - 10
+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
@@ -76,27 +78,81 @@ func (m statusCmp) View() string {
} else {
status += styles.Padded.
Foreground(styles.Base).
- Background(styles.LightGrey).
- Width(m.availableFooterMsgWidth()).
+ Background(styles.BackgroundDim).
+ Width(m.availableFooterMsgWidth(diagnostics)).
Render("")
}
+ status += diagnostics
status += m.model()
- status += versionWidget
return status
}
-func (m statusCmp) availableFooterMsgWidth() int {
- // -2 to accommodate padding
- return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
+func (m *statusCmp) projectDiagnostics() string {
+ errorDiagnostics := []protocol.Diagnostic{}
+ warnDiagnostics := []protocol.Diagnostic{}
+ hintDiagnostics := []protocol.Diagnostic{}
+ infoDiagnostics := []protocol.Diagnostic{}
+ for _, client := range m.lspClients {
+ for _, d := range client.GetDiagnostics() {
+ for _, diag := range d {
+ switch diag.Severity {
+ case protocol.SeverityError:
+ errorDiagnostics = append(errorDiagnostics, diag)
+ case protocol.SeverityWarning:
+ warnDiagnostics = append(warnDiagnostics, diag)
+ case protocol.SeverityHint:
+ hintDiagnostics = append(hintDiagnostics, diag)
+ case protocol.SeverityInformation:
+ infoDiagnostics = append(infoDiagnostics, diag)
+ }
+ }
+ }
+ }
+
+ if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
+ return "No diagnostics"
+ }
+
+ diagnostics := []string{}
+
+ if len(errorDiagnostics) > 0 {
+ errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
+ diagnostics = append(diagnostics, errStr)
+ }
+ if len(warnDiagnostics) > 0 {
+ warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
+ diagnostics = append(diagnostics, warnStr)
+ }
+ if len(hintDiagnostics) > 0 {
+ hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
+ diagnostics = append(diagnostics, hintStr)
+ }
+ if len(infoDiagnostics) > 0 {
+ infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
+ diagnostics = append(diagnostics, infoStr)
+ }
+
+ return strings.Join(diagnostics, " ")
+}
+
+func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics))
}
func (m statusCmp) model() string {
- model := models.SupportedModels[config.Get().Model.Coder]
+ cfg := config.Get()
+
+ coder, ok := cfg.Agents[config.AgentCoder]
+ if !ok {
+ return "Unknown"
+ }
+ model := models.SupportedModels[coder.Model]
return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
}
-func NewStatusCmp() tea.Model {
+func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
return &statusCmp{
messageTTL: 10 * time.Second,
+ lspClients: lspClients,
}
}
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
new file mode 100644
index 000000000..1d3c2b077
--- /dev/null
+++ b/internal/tui/components/dialog/help.go
@@ -0,0 +1,182 @@
+package dialog
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type helpCmp struct {
+ width int
+ height int
+ keys []key.Binding
+}
+
+func (h *helpCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (h *helpCmp) SetBindings(k []key.Binding) {
+ h.keys = k
+}
+
+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h.width = 80
+ h.height = msg.Height
+ }
+ return h, nil
+}
+
+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
+ seen := make(map[string]struct{})
+ result := make([]key.Binding, 0, len(bindings))
+
+ // Process bindings in reverse order
+ for i := len(bindings) - 1; i >= 0; i-- {
+ b := bindings[i]
+ k := strings.Join(b.Keys(), " ")
+ if _, ok := seen[k]; ok {
+ // duplicate, skip
+ continue
+ }
+ seen[k] = struct{}{}
+ // Add to the beginning of result to maintain original order
+ result = append([]key.Binding{b}, result...)
+ }
+
+ return result
+}
+
+func (h *helpCmp) render() string {
+ helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
+ helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
+ // Compile list of bindings to render
+ bindings := removeDuplicateBindings(h.keys)
+ // Enumerate through each group of bindings, populating a series of
+ // pairs of columns, one for keys, one for descriptions
+ var (
+ pairs []string
+ width int
+ rows = 12 - 2
+ )
+ for i := 0; i < len(bindings); i += rows {
+ var (
+ keys []string
+ descs []string
+ )
+ for j := i; j < min(i+rows, len(bindings)); j++ {
+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
+ }
+ // Render pair of columns; beyond the first pair, render a three space
+ // left margin, in order to visually separate the pairs.
+ var cols []string
+ if len(pairs) > 0 {
+ cols = []string{styles.BaseStyle.Render(" ")}
+ }
+
+ maxDescWidth := 0
+ for _, desc := range descs {
+ if maxDescWidth < lipgloss.Width(desc) {
+ maxDescWidth = lipgloss.Width(desc)
+ }
+ }
+ for i := range descs {
+ remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
+ if remainingWidth > 0 {
+ descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+ maxKeyWidth := 0
+ for _, key := range keys {
+ if maxKeyWidth < lipgloss.Width(key) {
+ maxKeyWidth = lipgloss.Width(key)
+ }
+ }
+ for i := range keys {
+ remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
+ if remainingWidth > 0 {
+ keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+
+ cols = append(cols,
+ strings.Join(keys, "\n"),
+ strings.Join(descs, "\n"),
+ )
+
+ pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
+ // check whether it exceeds the maximum width avail (the width of the
+ // terminal, subtracting 2 for the borders).
+ width += lipgloss.Width(pair)
+ if width > h.width-2 {
+ break
+ }
+ pairs = append(pairs, pair)
+ }
+
+ // https://github.com/charmbracelet/lipgloss/issues/209
+ if len(pairs) > 1 {
+ prefix := pairs[:len(pairs)-1]
+ lastPair := pairs[len(pairs)-1]
+ prefix = append(prefix, lipgloss.Place(
+ lipgloss.Width(lastPair), // width
+ lipgloss.Height(prefix[0]), // height
+ lipgloss.Left, // x
+ lipgloss.Top, // y
+ lastPair, // content
+ lipgloss.WithWhitespaceBackground(styles.Background), // background
+ ))
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ prefix...,
+ ),
+ )
+ return content
+ }
+ // Join pairs of columns and enclose in a border
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ pairs...,
+ ),
+ )
+ return content
+}
+
+func (h *helpCmp) View() string {
+ content := h.render()
+ header := styles.BaseStyle.
+ Bold(true).
+ Width(lipgloss.Width(content)).
+ Foreground(styles.PrimaryColor).
+ Render("Keyboard Shortcuts")
+
+ return styles.BaseStyle.Padding(1).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(styles.ForgroundDim).
+ Width(h.width).
+ BorderBackground(styles.Background).
+ Render(
+ lipgloss.JoinVertical(lipgloss.Center,
+ header,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
+ content,
+ ),
+ )
+}
+
+type HelpCmp interface {
+ tea.Model
+ SetBindings([]key.Binding)
+}
+
+func NewHelpCmp() HelpCmp {
+ return &helpCmp{}
+}
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index d147f89cd..9c55effde 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -12,12 +12,9 @@ import (
"github.com/kujtimiihoxha/termai/internal/diff"
"github.com/kujtimiihoxha/termai/internal/llm/tools"
"github.com/kujtimiihoxha/termai/internal/permission"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
)
type PermissionAction string
@@ -35,69 +32,64 @@ type PermissionResponseMsg struct {
Action PermissionAction
}
-// PermissionDialog interface for permission dialog component
-type PermissionDialog interface {
+// PermissionDialogCmp interface for permission dialog component
+type PermissionDialogCmp interface {
tea.Model
- layout.Sizeable
layout.Bindings
+ SetPermissions(permission permission.PermissionRequest)
}
-type keyMap struct {
- ChangeFocus key.Binding
+type permissionsMapping struct {
+ LeftRight key.Binding
+ EnterSpace key.Binding
+ Allow key.Binding
+ AllowSession key.Binding
+ Deny key.Binding
+ Tab key.Binding
}
-var keyMapValue = keyMap{
- ChangeFocus: key.NewBinding(
+var permissionsKeys = permissionsMapping{
+ LeftRight: key.NewBinding(
+ key.WithKeys("left", "right"),
+ key.WithHelp("←/→", "switch options"),
+ ),
+ EnterSpace: key.NewBinding(
+ key.WithKeys("enter", " "),
+ key.WithHelp("enter/space", "confirm"),
+ ),
+ Allow: key.NewBinding(
+ key.WithKeys("a"),
+ key.WithHelp("a", "allow"),
+ ),
+ AllowSession: key.NewBinding(
+ key.WithKeys("A"),
+ key.WithHelp("A", "allow for session"),
+ ),
+ Deny: key.NewBinding(
+ key.WithKeys("d"),
+ key.WithHelp("d", "deny"),
+ ),
+ Tab: key.NewBinding(
key.WithKeys("tab"),
- key.WithHelp("tab", "change focus"),
+ key.WithHelp("tab", "switch options"),
),
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
- form *huh.Form
width int
height int
permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
- r *glamour.TermRenderer
contentViewPort viewport.Model
- isViewportFocus bool
- selectOption *huh.Select[string]
-}
+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
-// formatDiff formats a diff string with colors for additions and deletions
-func formatDiff(diffText string) string {
- lines := strings.Split(diffText, "\n")
- var formattedLines []string
-
- // Define styles for different line types
- addStyle := lipgloss.NewStyle().Foreground(styles.Green)
- removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
- headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
- contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
-
- // Process each line
- for _, line := range lines {
- if strings.HasPrefix(line, "+") {
- formattedLines = append(formattedLines, addStyle.Render(line))
- } else if strings.HasPrefix(line, "-") {
- formattedLines = append(formattedLines, removeStyle.Render(line))
- } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
- formattedLines = append(formattedLines, headerStyle.Render(line))
- } else if strings.HasPrefix(line, " ") {
- formattedLines = append(formattedLines, contextStyle.Render(line))
- } else {
- formattedLines = append(formattedLines, line)
- }
- }
-
- // Join all formatted lines
- return strings.Join(formattedLines, "\n")
+ diffCache map[string]string
+ markdownCache map[string]string
}
func (p *permissionDialogCmp) Init() tea.Cmd {
- return nil
+ return p.contentViewPort.Init()
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -106,373 +98,363 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.windowSize = msg
+ p.SetSize()
+ p.markdownCache = make(map[string]string)
+ p.diffCache = make(map[string]string)
case tea.KeyMsg:
- if key.Matches(msg, keyMapValue.ChangeFocus) {
- p.isViewportFocus = !p.isViewportFocus
- if p.isViewportFocus {
- p.selectOption.Blur()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.ReportInfo("Viewing content - use arrow keys to scroll"),
- ))
- } else {
- p.selectOption.Focus()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.CmdHandler(util.ReportInfo("Select an action")),
- ))
- }
- return p, tea.Batch(cmds...)
- }
- }
-
- if p.isViewportFocus {
- viewPort, cmd := p.contentViewPort.Update(msg)
- p.contentViewPort = viewPort
- cmds = append(cmds, cmd)
- } else {
- form, cmd := p.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- p.form = f
+ switch {
+ case key.Matches(msg, permissionsKeys.LeftRight) || key.Matches(msg, permissionsKeys.Tab):
+ // Change selected option
+ p.selectedOption = (p.selectedOption + 1) % 3
+ return p, nil
+ case key.Matches(msg, permissionsKeys.EnterSpace):
+ // Select current option
+ return p, p.selectCurrentOption()
+ case key.Matches(msg, permissionsKeys.Allow):
+ // Select Allow
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
+ case key.Matches(msg, permissionsKeys.AllowSession):
+ // Select Allow for session
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
+ case key.Matches(msg, permissionsKeys.Deny):
+ // Select Deny
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
+ default:
+ // Pass other keys to viewport
+ viewPort, cmd := p.contentViewPort.Update(msg)
+ p.contentViewPort = viewPort
cmds = append(cmds, cmd)
}
-
- if p.form.State == huh.StateCompleted {
- // Get the selected action
- action := p.form.GetString("action")
-
- // Close the dialog and return the response
- return p, tea.Batch(
- util.CmdHandler(core.DialogCloseMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
- )
- }
}
+
return p, tea.Batch(cmds...)
}
-func (p *permissionDialogCmp) render() string {
- keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
- valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
+ var action PermissionAction
- form := p.form.View()
-
- headerParts := []string{
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
- " ",
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
- " ",
+ switch p.selectedOption {
+ case 0:
+ action = PermissionAllow
+ case 1:
+ action = PermissionAllowForSession
+ case 2:
+ action = PermissionDeny
}
- // Create the header content first so it can be used in all cases
- headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
- glamour.WithWordWrap(p.width-10),
- glamour.WithEmoji(),
- )
-
- // Handle different tool types
- switch p.permission.ToolName {
- case tools.BashToolName:
- pr := p.permission.Params.(tools.BashPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Command:"))
- content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on content
- contentLines := len(strings.Split(renderedContent, "\n"))
- // Set a reasonable min/max for the viewport height
- minContentHeight := 3
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
-
- // Add some padding to the content lines
- contentHeight := contentLines + 2
- contentHeight = max(contentHeight, minContentHeight)
- contentHeight = min(contentHeight, maxContentHeight)
- p.contentViewPort.Height = contentHeight
-
- p.contentViewPort.SetContent(renderedContent)
+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
+}
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
+func (p *permissionDialogCmp) renderButtons() string {
+ allowStyle := styles.BaseStyle
+ allowSessionStyle := styles.BaseStyle
+ denyStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ // Style the selected button
+ switch p.selectedOption {
+ case 0:
+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 1:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 2:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ }
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
+
+ content := lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ allowButton,
+ spacerStyle.Render(" "),
+ allowSessionButton,
+ spacerStyle.Render(" "),
+ denyButton,
+ spacerStyle.Render(" "),
+ )
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ remainingWidth := p.width - lipgloss.Width(content)
+ if remainingWidth > 0 {
+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
+ }
+ return content
+}
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+func (p *permissionDialogCmp) renderHeader() string {
+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
+ toolValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(toolKey)).
+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
+ pathValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(pathKey)).
+ Render(fmt.Sprintf(": %s", p.permission.Path))
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ headerParts := []string{
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ toolKey,
+ toolValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ pathKey,
+ pathValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ }
+ // Add tool-specific header information
+ switch p.permission.ToolName {
+ case tools.BashToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
case tools.EditToolName:
- pr := p.permission.Params.(tools.EditPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Update"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Format the diff with colors
-
- // Set up viewport for the diff content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
- if err != nil {
- diff = fmt.Sprintf("Error formatting diff: %v", err)
- }
- p.contentViewPort.SetContent(diff)
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.WriteToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.FetchToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
+ }
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+}
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+func (p *permissionDialogCmp) renderBashContent() string {
+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
+
+ finalContent := styles.BaseStyle.
+ Width(p.contentViewPort.Width).
+ Render(renderedContent)
+ p.contentViewPort.SetContent(finalContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+func (p *permissionDialogCmp) renderEditContent() string {
+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+func (p *permissionDialogCmp) renderWriteContent() string {
+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
+ // Use the cache for diff rendering
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
- case tools.WriteToolName:
- pr := p.permission.Params.(tools.WritePermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Content"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Set up viewport for the content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
- if err != nil {
- diff = fmt.Sprintf("Error formatting diff: %v", err)
- }
p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
+func (p *permissionDialogCmp) renderFetchContent() string {
+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
-
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ p.contentViewPort.SetContent(renderedContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) renderDefaultContent() string {
+ content := p.permission.Description
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
+ glamour.WithWordWrap(p.width-10),
)
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- case tools.FetchToolName:
- pr := p.permission.Params.(tools.FetchPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
- content := p.permission.Description
+ p.contentViewPort.SetContent(renderedContent)
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
+ if renderedContent == "" {
+ return ""
+ }
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
+ return p.styleViewport()
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
+func (p *permissionDialogCmp) styleViewport() string {
+ contentStyle := lipgloss.NewStyle().
+ Background(styles.Background)
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ return contentStyle.Render(p.contentViewPort.View())
+}
+func (p *permissionDialogCmp) render() string {
+ title := styles.BaseStyle.
+ Bold(true).
+ Width(p.width - 4).
+ Foreground(styles.PrimaryColor).
+ Render("Permission Required")
+ // Render header
+ headerContent := p.renderHeader()
+ // Render buttons
+ buttons := p.renderButtons()
+
+ // Calculate content height dynamically based on window size
+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
+ p.contentViewPort.Width = p.width - 4
+
+ // Render content based on tool type
+ var contentFinal string
+ switch p.permission.ToolName {
+ case tools.BashToolName:
+ contentFinal = p.renderBashContent()
+ case tools.EditToolName:
+ contentFinal = p.renderEditContent()
+ case tools.WriteToolName:
+ contentFinal = p.renderWriteContent()
+ case tools.FetchToolName:
+ contentFinal = p.renderFetchContent()
default:
- content := p.permission.Description
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
-
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
+ contentFinal = p.renderDefaultContent()
+ }
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
+ content := lipgloss.JoinVertical(
+ lipgloss.Top,
+ title,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
+ headerContent,
+ contentFinal,
+ buttons,
+ )
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ return styles.BaseStyle.
+ Padding(1, 0, 0, 1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(p.width).
+ Height(p.height).
+ Render(
+ content,
)
- }
}
func (p *permissionDialogCmp) View() string {
return p.render()
}
-func (p *permissionDialogCmp) GetSize() (int, int) {
- return p.width, p.height
+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(helpKeys)
}
-func (p *permissionDialogCmp) SetSize(width int, height int) {
- p.width = width
- p.height = height
- p.form = p.form.WithWidth(width)
+func (p *permissionDialogCmp) SetSize() {
+ if p.permission.ID == "" {
+ return
+ }
+ switch p.permission.ToolName {
+ case tools.BashToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.4)
+ p.height = int(float64(p.windowSize.Height) * 0.3)
+ case tools.EditToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.8)
+ p.height = int(float64(p.windowSize.Height) * 0.8)
+ case tools.WriteToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.8)
+ p.height = int(float64(p.windowSize.Height) * 0.8)
+ case tools.FetchToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.4)
+ p.height = int(float64(p.windowSize.Height) * 0.3)
+ default:
+ p.width = int(float64(p.windowSize.Width) * 0.7)
+ p.height = int(float64(p.windowSize.Height) * 0.5)
+ }
}
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
- return p.form.KeyBinds()
+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
+ p.permission = permission
+ p.SetSize()
}
-func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
- // Create a note field for displaying the content
+// Helper to get or set cached diff content
+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
+ if cached, ok := c.diffCache[key]; ok {
+ return cached
+ }
- // Create select field for the permission options
- selectOption := huh.NewSelect[string]().
- Key("action").
- Options(
- huh.NewOption("Allow", string(PermissionAllow)),
- huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
- huh.NewOption("Deny", string(PermissionDeny)),
- ).
- Title("Select an action")
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error formatting diff: %v", err)
+ }
- // Apply theme
- theme := styles.HuhTheme()
+ c.diffCache[key] = content
- // Setup form width and height
- form := huh.NewForm(huh.NewGroup(selectOption)).
- WithShowHelp(false).
- WithTheme(theme).
- WithShowErrors(false)
+ return content
+}
- // Focus the form for immediate interaction
- selectOption.Focus()
+// Helper to get or set cached markdown content
+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
+ if cached, ok := c.markdownCache[key]; ok {
+ return cached
+ }
- return &permissionDialogCmp{
- permission: permission,
- form: form,
- selectOption: selectOption,
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error rendering markdown: %v", err)
}
-}
-// NewPermissionDialogCmd creates a new permission dialog command
-func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
- permDialog := newPermissionDialogCmp(permission)
-
- // Create the dialog layout
- dialogPane := layout.NewSinglePane(
- permDialog.(*permissionDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
- layout.TopMiddleBorder: " Permission Required ",
- }),
- )
+ c.markdownCache[key] = content
- // Focus the dialog
- dialogPane.Focus()
- widthRatio := 0.7
- heightRatio := 0.6
- minWidth := 100
- minHeight := 30
+ return content
+}
- // Make the dialog size more appropriate for different tools
- switch permission.ToolName {
- case tools.BashToolName:
- // For bash commands, use a more compact dialog
- widthRatio = 0.7
- heightRatio = 0.4 // Reduced from 0.5
- minWidth = 100
- minHeight = 20 // Reduced from 30
+func NewPermissionDialogCmp() PermissionDialogCmp {
+ // Create viewport for content
+ contentViewport := viewport.New(0, 0)
+
+ return &permissionDialogCmp{
+ contentViewPort: contentViewport,
+ selectedOption: 0, // Default to "Allow"
+ diffCache: make(map[string]string),
+ markdownCache: make(map[string]string),
}
- // Return the dialog command
- return util.CmdHandler(core.DialogMsg{
- Content: dialogPane,
- WidthRatio: widthRatio,
- HeightRatio: heightRatio,
- MinWidth: minWidth,
- MinHeight: minHeight,
- })
}
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index 60c1fc0d2..10d9ba8a2 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -1,28 +1,58 @@
package dialog
import (
+ "strings"
+
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
+ "github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
)
const question = "Are you sure you want to quit?"
+type CloseQuitMsg struct{}
+
type QuitDialog interface {
tea.Model
- layout.Sizeable
layout.Bindings
}
type quitDialogCmp struct {
- form *huh.Form
- width int
- height int
+ selectedNo bool
+}
+
+type helpMapping struct {
+ LeftRight key.Binding
+ EnterSpace key.Binding
+ Yes key.Binding
+ No key.Binding
+ Tab key.Binding
+}
+
+var helpKeys = helpMapping{
+ LeftRight: key.NewBinding(
+ key.WithKeys("left", "right"),
+ key.WithHelp("←/→", "switch options"),
+ ),
+ EnterSpace: key.NewBinding(
+ key.WithKeys("enter", " "),
+ key.WithHelp("enter/space", "confirm"),
+ ),
+ Yes: key.NewBinding(
+ key.WithKeys("y", "Y"),
+ key.WithHelp("y/Y", "yes"),
+ ),
+ No: key.NewBinding(
+ key.WithKeys("n", "N"),
+ key.WithHelp("n/N", "no"),
+ ),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch options"),
+ ),
}
func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
}
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- form, cmd := q.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- q.form = f
- cmds = append(cmds, cmd)
- }
-
- if q.form.State == huh.StateCompleted {
- v := q.form.GetBool("quit")
- if v {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+ q.selectedNo = !q.selectedNo
+ return q, nil
+ case key.Matches(msg, helpKeys.EnterSpace):
+ if !q.selectedNo {
+ return q, tea.Quit
+ }
+ return q, util.CmdHandler(CloseQuitMsg{})
+ case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
+ case key.Matches(msg, helpKeys.No):
+ return q, util.CmdHandler(CloseQuitMsg{})
}
- cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
}
-
- return q, tea.Batch(cmds...)
+ return q, nil
}
func (q *quitDialogCmp) View() string {
- return q.form.View()
-}
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ if q.selectedNo {
+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ } else {
+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ }
-func (q *quitDialogCmp) GetSize() (int, int) {
- return q.width, q.height
-}
+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
+ noButton := noStyle.Padding(0, 1).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
+
+ width := lipgloss.Width(question)
+ remainingWidth := width - lipgloss.Width(buttons)
+ if remainingWidth > 0 {
+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
+ }
-func (q *quitDialogCmp) SetSize(width int, height int) {
- q.width = width
- q.height = height
- q.form = q.form.WithWidth(width).WithHeight(height)
+ content := styles.BaseStyle.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Center,
+ question,
+ "",
+ buttons,
+ ),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
- return q.form.KeyBinds()
+ return layout.KeyMapToSlice(helpKeys)
}
-func newQuitDialogCmp() QuitDialog {
- confirm := huh.NewConfirm().
- Title(question).
- Affirmative("Yes!").
- Key("quit").
- Negative("No.")
-
- theme := styles.HuhTheme()
- theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
- theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
- form := huh.NewForm(huh.NewGroup(confirm)).
- WithShowHelp(false).
- WithWidth(0).
- WithHeight(0).
- WithTheme(theme).
- WithShowErrors(false)
- confirm.Focus()
+func NewQuitCmp() QuitDialog {
return &quitDialogCmp{
- form: form,
+ selectedNo: true,
}
}
-
-func NewQuitDialogCmd() tea.Cmd {
- content := layout.NewSinglePane(
- newQuitDialogCmp().(*quitDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- )
- content.Focus()
- return util.CmdHandler(core.DialogMsg{
- Content: content,
- WidthRatio: 0.2,
- HeightRatio: 0.1,
- MinWidth: 40,
- MinHeight: 5,
- })
-}
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index dbace5508..18eb1a526 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -16,10 +16,8 @@ import (
type DetailComponent interface {
tea.Model
- layout.Focusable
layout.Sizeable
layout.Bindings
- layout.Bordered
}
type detailCmp struct {
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index 9500059b1..6e8eb58b1 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -16,22 +16,14 @@ import (
type TableComponent interface {
tea.Model
- layout.Focusable
layout.Sizeable
layout.Bindings
- layout.Bordered
}
type tableCmp struct {
table table.Model
}
-func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
- return map[layout.BorderPosition]string{
- layout.TopLeftBorder: "Logs",
- }
-}
-
type selectedLogMsg logging.LogMessage
func (i *tableCmp) Init() tea.Cmd {
@@ -74,20 +66,6 @@ func (i *tableCmp) View() string {
return i.table.View()
}
-func (i *tableCmp) Blur() tea.Cmd {
- i.table.Blur()
- return nil
-}
-
-func (i *tableCmp) Focus() tea.Cmd {
- i.table.Focus()
- return nil
-}
-
-func (i *tableCmp) IsFocused() bool {
- return i.table.Focused()
-}
-
func (i *tableCmp) GetSize() (int, int) {
return i.table.Width(), i.table.Height()
}
diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go
deleted file mode 100644
index b659775e0..000000000
--- a/internal/tui/components/repl/editor.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package repl
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/kujtimiihoxha/vimtea"
- "golang.org/x/net/context"
-)
-
-type EditorCmp interface {
- tea.Model
- layout.Focusable
- layout.Sizeable
- layout.Bordered
- layout.Bindings
-}
-
-type editorCmp struct {
- app *app.App
- editor vimtea.Editor
- editorMode vimtea.EditorMode
- sessionID string
- focused bool
- width int
- height int
- cancelMessage context.CancelFunc
-}
-
-type editorKeyMap struct {
- SendMessage key.Binding
- SendMessageI key.Binding
- CancelMessage key.Binding
- InsertMode key.Binding
- NormaMode key.Binding
- VisualMode key.Binding
- VisualLineMode key.Binding
-}
-
-var editorKeyMapValue = editorKeyMap{
- SendMessage: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "send message normal mode"),
- ),
- SendMessageI: key.NewBinding(
- key.WithKeys("ctrl+s"),
- key.WithHelp("ctrl+s", "send message insert mode"),
- ),
- CancelMessage: key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("ctrl+x", "cancel current message"),
- ),
- InsertMode: key.NewBinding(
- key.WithKeys("i"),
- key.WithHelp("i", "insert mode"),
- ),
- NormaMode: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "normal mode"),
- ),
- VisualMode: key.NewBinding(
- key.WithKeys("v"),
- key.WithHelp("v", "visual mode"),
- ),
- VisualLineMode: key.NewBinding(
- key.WithKeys("V"),
- key.WithHelp("V", "visual line mode"),
- ),
-}
-
-func (m *editorCmp) Init() tea.Cmd {
- return m.editor.Init()
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case vimtea.EditorModeMsg:
- m.editorMode = msg.Mode
- case SelectedSessionMsg:
- if msg.SessionID != m.sessionID {
- m.sessionID = msg.SessionID
- }
- }
- if m.IsFocused() {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, editorKeyMapValue.SendMessage):
- if m.editorMode == vimtea.ModeNormal {
- return m, m.Send()
- }
- case key.Matches(msg, editorKeyMapValue.SendMessageI):
- if m.editorMode == vimtea.ModeInsert {
- return m, m.Send()
- }
- case key.Matches(msg, editorKeyMapValue.CancelMessage):
- return m, m.Cancel()
- }
- }
- u, cmd := m.editor.Update(msg)
- m.editor = u.(vimtea.Editor)
- return m, cmd
- }
- return m, nil
-}
-
-func (m *editorCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
- title := "New Message"
- if m.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- return map[layout.BorderPosition]string{
- layout.BottomLeftBorder: title,
- }
-}
-
-func (m *editorCmp) Focus() tea.Cmd {
- m.focused = true
- return m.editor.Tick()
-}
-
-func (m *editorCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *editorCmp) IsFocused() bool {
- return m.focused
-}
-
-func (m *editorCmp) SetSize(width int, height int) {
- m.width = width
- m.height = height
- m.editor.SetSize(width, height)
-}
-
-func (m *editorCmp) Cancel() tea.Cmd {
- if m.cancelMessage == nil {
- return util.ReportWarn("No message to cancel")
- }
-
- m.cancelMessage()
- m.cancelMessage = nil
- return util.ReportWarn("Message cancelled")
-}
-
-func (m *editorCmp) Send() tea.Cmd {
- if m.cancelMessage != nil {
- return util.ReportWarn("Assistant is still working on the previous message")
- }
-
- messages, err := m.app.Messages.List(context.Background(), m.sessionID)
- if err != nil {
- return util.ReportError(err)
- }
- if hasUnfinishedMessages(messages) {
- return util.ReportWarn("Assistant is still working on the previous message")
- }
-
- content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
- if len(content) == 0 {
- return util.ReportWarn("Message is empty")
- }
- ctx, cancel := context.WithCancel(context.Background())
- m.cancelMessage = cancel
- go func() {
- defer cancel()
- m.app.CoderAgent.Generate(ctx, m.sessionID, content)
- m.cancelMessage = nil
- }()
-
- return m.editor.Reset()
-}
-
-func (m *editorCmp) View() string {
- return m.editor.View()
-}
-
-func (m *editorCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(editorKeyMapValue)
-}
-
-func NewEditorCmp(app *app.App) EditorCmp {
- editor := vimtea.NewEditor(
- vimtea.WithFileName("message.md"),
- )
- return &editorCmp{
- app: app,
- editor: editor,
- }
-}
diff --git a/internal/tui/components/repl/messages.go b/internal/tui/components/repl/messages.go
deleted file mode 100644
index 260be220e..000000000
--- a/internal/tui/components/repl/messages.go
+++ /dev/null
@@ -1,513 +0,0 @@
-package repl
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/llm/agent"
- "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
- "github.com/kujtimiihoxha/termai/internal/message"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/session"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-type MessagesCmp interface {
- tea.Model
- layout.Focusable
- layout.Bordered
- layout.Sizeable
- layout.Bindings
-}
-
-type messagesCmp struct {
- app *app.App
- messages []message.Message
- selectedMsgIdx int // Index of the selected message
- session session.Session
- viewport viewport.Model
- mdRenderer *glamour.TermRenderer
- width int
- height int
- focused bool
- cachedView string
-}
-
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case pubsub.Event[message.Message]:
- if msg.Type == pubsub.CreatedEvent {
- if msg.Payload.SessionID == m.session.ID {
- m.messages = append(m.messages, msg.Payload)
- m.renderView()
- m.viewport.GotoBottom()
- }
- for _, v := range m.messages {
- for _, c := range v.ToolCalls() {
- // the message is being added to the session of a tool called
- if c.ID == msg.Payload.SessionID {
- m.renderView()
- m.viewport.GotoBottom()
- }
- }
- }
- } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
- for i, v := range m.messages {
- if v.ID == msg.Payload.ID {
- m.messages[i] = msg.Payload
- m.renderView()
- if i == len(m.messages)-1 {
- m.viewport.GotoBottom()
- }
- break
- }
- }
- }
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
- m.session = msg.Payload
- }
- case SelectedSessionMsg:
- m.session, _ = m.app.Sessions.Get(context.Background(), msg.SessionID)
- m.messages, _ = m.app.Messages.List(context.Background(), m.session.ID)
- m.renderView()
- m.viewport.GotoBottom()
- }
- if m.focused {
- u, cmd := m.viewport.Update(msg)
- m.viewport = u
- return m, cmd
- }
- return m, nil
-}
-
-func borderColor(role message.MessageRole) lipgloss.TerminalColor {
- switch role {
- case message.Assistant:
- return styles.Mauve
- case message.User:
- return styles.Rosewater
- }
- return styles.Blue
-}
-
-func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
- role := ""
- icon := ""
- switch msgRole {
- case message.Assistant:
- role = "Assistant"
- icon = styles.BotIcon
- case message.User:
- role = "User"
- icon = styles.UserIcon
- }
- return map[layout.BorderPosition]string{
- layout.TopLeftBorder: lipgloss.NewStyle().
- Padding(0, 1).
- Bold(true).
- Foreground(styles.Crust).
- Background(borderColor(msgRole)).
- Render(fmt.Sprintf("%s %s ", role, icon)),
- layout.TopRightBorder: lipgloss.NewStyle().
- Padding(0, 1).
- Bold(true).
- Foreground(styles.Crust).
- Background(borderColor(msgRole)).
- Render(fmt.Sprintf("#%d ", currentMessage)),
- }
-}
-
-func hasUnfinishedMessages(messages []message.Message) bool {
- if len(messages) == 0 {
- return false
- }
- for _, msg := range messages {
- if !msg.IsFinished() {
- return true
- }
- }
- return false
-}
-
-func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
- allParts := []string{content}
-
- leftPaddingValue := 4
- connectorStyle := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true)
-
- toolCallStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Peach).
- Width(m.width-leftPaddingValue-5).
- Padding(0, 1)
-
- toolResultStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Green).
- Width(m.width-leftPaddingValue-5).
- Padding(0, 1)
-
- leftPadding := lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue)
-
- runningStyle := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true)
-
- renderTool := func(toolCall message.ToolCall) string {
- toolHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Blue).
- Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
-
- var paramLines []string
- var args map[string]interface{}
- var paramOrder []string
-
- json.Unmarshal([]byte(toolCall.Input), &args)
-
- for key := range args {
- paramOrder = append(paramOrder, key)
- }
- sort.Strings(paramOrder)
-
- for _, name := range paramOrder {
- value := args[name]
- paramName := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true).
- Render(name)
-
- truncate := m.width - leftPaddingValue*2 - 10
- if len(fmt.Sprintf("%v", value)) > truncate {
- value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
- paramValue := fmt.Sprintf("%v", value)
- paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
- }
-
- paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
-
- toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
- return toolCallStyle.Render(toolContent)
- }
-
- findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
- for _, msg := range messages {
- if msg.Role == message.Tool {
- for _, result := range msg.ToolResults() {
- if result.ToolCallID == toolCallID {
- return &result
- }
- }
- }
- }
- return nil
- }
-
- renderToolResult := func(result message.ToolResult) string {
- resultHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Green).
- Render(fmt.Sprintf("%s Result", styles.CheckIcon))
-
- // Use the same style for both header and border if it's an error
- borderColor := styles.Green
- if result.IsError {
- resultHeader = lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Red).
- Render(fmt.Sprintf("%s Error", styles.ErrorIcon))
- borderColor = styles.Red
- }
-
- truncate := 200
- content := result.Content
- if len(content) > truncate {
- content = content[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
-
- resultContent := lipgloss.JoinVertical(lipgloss.Left, resultHeader, content)
- return toolResultStyle.BorderForeground(borderColor).Render(resultContent)
- }
-
- connector := connectorStyle.Render("└─> Tool Calls:")
- allParts = append(allParts, connector)
-
- for _, toolCall := range tools {
- toolOutput := renderTool(toolCall)
- allParts = append(allParts, leftPadding.Render(toolOutput))
-
- result := findToolResult(toolCall.ID, futureMessages)
- if result != nil {
-
- resultOutput := renderToolResult(*result)
- allParts = append(allParts, leftPadding.Render(resultOutput))
-
- } else if toolCall.Name == agent.AgentToolName {
-
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, leftPadding.Render(runningIndicator))
- taskSessionMessages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
- for _, msg := range taskSessionMessages {
- if msg.Role == message.Assistant {
- for _, toolCall := range msg.ToolCalls() {
- toolHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Blue).
- Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
-
- var paramLines []string
- var args map[string]interface{}
- var paramOrder []string
-
- json.Unmarshal([]byte(toolCall.Input), &args)
-
- for key := range args {
- paramOrder = append(paramOrder, key)
- }
- sort.Strings(paramOrder)
-
- for _, name := range paramOrder {
- value := args[name]
- paramName := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true).
- Render(name)
-
- truncate := 50
- if len(fmt.Sprintf("%v", value)) > truncate {
- value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
- paramValue := fmt.Sprintf("%v", value)
- paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
- }
-
- paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
- toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
- toolOutput := toolCallStyle.BorderForeground(styles.Teal).MaxWidth(m.width - leftPaddingValue*2 - 2).Render(toolContent)
- allParts = append(allParts, lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue*2).Render(toolOutput))
- }
- }
- }
-
- } else {
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, " "+runningIndicator)
- }
- }
-
- for _, msg := range futureMessages {
- if msg.Content().String() != "" || msg.FinishReason() == "canceled" {
- break
- }
-
- for _, toolCall := range msg.ToolCalls() {
- toolOutput := renderTool(toolCall)
- allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
-
- result := findToolResult(toolCall.ID, futureMessages)
- if result != nil {
- resultOutput := renderToolResult(*result)
- allParts = append(allParts, " "+strings.ReplaceAll(resultOutput, "\n", "\n "))
- } else {
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, " "+runningIndicator)
- }
- }
- }
-
- return lipgloss.JoinVertical(lipgloss.Left, allParts...)
-}
-
-func (m *messagesCmp) renderView() {
- stringMessages := make([]string, 0)
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
- glamour.WithWordWrap(m.width-20),
- glamour.WithEmoji(),
- )
- textStyle := lipgloss.NewStyle().Width(m.width - 4)
- currentMessage := 1
- displayedMsgCount := 0 // Track the actual displayed messages count
-
- prevMessageWasUser := false
- for inx, msg := range m.messages {
- content := msg.Content().String()
- if content != "" || prevMessageWasUser || msg.FinishReason() == "canceled" {
- if msg.ReasoningContent().String() != "" && content == "" {
- content = msg.ReasoningContent().String()
- } else if content == "" {
- content = "..."
- }
- if msg.FinishReason() == "canceled" {
- content, _ = r.Render(content)
- content += lipgloss.NewStyle().Padding(1, 0, 0, 1).Foreground(styles.Error).Render(styles.ErrorIcon + " Canceled")
- } else {
- content, _ = r.Render(content)
- }
-
- isSelected := inx == m.selectedMsgIdx
-
- border := lipgloss.DoubleBorder()
- activeColor := borderColor(msg.Role)
-
- if isSelected {
- activeColor = styles.Primary // Use primary color for selected message
- }
-
- content = layout.Borderize(
- textStyle.Render(content),
- layout.BorderOptions{
- InactiveBorder: border,
- ActiveBorder: border,
- ActiveColor: activeColor,
- InactiveColor: borderColor(msg.Role),
- EmbeddedText: borderText(msg.Role, currentMessage),
- },
- )
- if len(msg.ToolCalls()) > 0 {
- content = m.renderMessageWithToolCall(content, msg.ToolCalls(), m.messages[inx+1:])
- }
- stringMessages = append(stringMessages, content)
- currentMessage++
- displayedMsgCount++
- }
- if msg.Role == message.User && msg.Content().String() != "" {
- prevMessageWasUser = true
- } else {
- prevMessageWasUser = false
- }
- }
- m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
-}
-
-func (m *messagesCmp) View() string {
- return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
-}
-
-func (m *messagesCmp) BindingKeys() []key.Binding {
- keys := layout.KeyMapToSlice(m.viewport.KeyMap)
-
- return keys
-}
-
-func (m *messagesCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-func (m *messagesCmp) projectDiagnostics() string {
- errorDiagnostics := []protocol.Diagnostic{}
- warnDiagnostics := []protocol.Diagnostic{}
- hintDiagnostics := []protocol.Diagnostic{}
- infoDiagnostics := []protocol.Diagnostic{}
- for _, client := range m.app.LSPClients {
- for _, d := range client.GetDiagnostics() {
- for _, diag := range d {
- switch diag.Severity {
- case protocol.SeverityError:
- errorDiagnostics = append(errorDiagnostics, diag)
- case protocol.SeverityWarning:
- warnDiagnostics = append(warnDiagnostics, diag)
- case protocol.SeverityHint:
- hintDiagnostics = append(hintDiagnostics, diag)
- case protocol.SeverityInformation:
- infoDiagnostics = append(infoDiagnostics, diag)
- }
- }
- }
- }
-
- if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
- return "No diagnostics"
- }
-
- diagnostics := []string{}
-
- if len(errorDiagnostics) > 0 {
- errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
- diagnostics = append(diagnostics, errStr)
- }
- if len(warnDiagnostics) > 0 {
- warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
- diagnostics = append(diagnostics, warnStr)
- }
- if len(hintDiagnostics) > 0 {
- hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
- diagnostics = append(diagnostics, hintStr)
- }
- if len(infoDiagnostics) > 0 {
- infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
- diagnostics = append(diagnostics, infoStr)
- }
-
- return strings.Join(diagnostics, " ")
-}
-
-func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
- title := m.session.Title
- titleWidth := m.width / 2
- if len(title) > titleWidth {
- title = title[:titleWidth] + "..."
- }
- if m.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- borderTest := map[layout.BorderPosition]string{
- layout.TopLeftBorder: title,
- layout.BottomRightBorder: m.projectDiagnostics(),
- }
- if hasUnfinishedMessages(m.messages) {
- borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
- } else {
- borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Text).Render("Sleeping " + styles.SleepIcon + " ")
- }
-
- return borderTest
-}
-
-func (m *messagesCmp) Focus() tea.Cmd {
- m.focused = true
- return nil
-}
-
-func (m *messagesCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *messagesCmp) IsFocused() bool {
- return m.focused
-}
-
-func (m *messagesCmp) SetSize(width int, height int) {
- m.width = width
- m.height = height
- m.viewport.Width = width - 2 // padding
- m.viewport.Height = height - 2 // padding
- m.renderView()
-}
-
-func (m *messagesCmp) Init() tea.Cmd {
- return nil
-}
-
-func NewMessagesCmp(app *app.App) MessagesCmp {
- return &messagesCmp{
- app: app,
- messages: []message.Message{},
- viewport: viewport.New(0, 0),
- }
-}
diff --git a/internal/tui/components/repl/sessions.go b/internal/tui/components/repl/sessions.go
deleted file mode 100644
index c83c40367..000000000
--- a/internal/tui/components/repl/sessions.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package repl
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/session"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-)
-
-type SessionsCmp interface {
- tea.Model
- layout.Sizeable
- layout.Focusable
- layout.Bordered
- layout.Bindings
-}
-type sessionsCmp struct {
- app *app.App
- list list.Model
- focused bool
-}
-
-type listItem struct {
- id, title, desc string
-}
-
-func (i listItem) Title() string { return i.title }
-func (i listItem) Description() string { return i.desc }
-func (i listItem) FilterValue() string { return i.title }
-
-type InsertSessionsMsg struct {
- sessions []session.Session
-}
-
-type SelectedSessionMsg struct {
- SessionID string
-}
-
-type sessionsKeyMap struct {
- Select key.Binding
-}
-
-var sessionKeyMapValue = sessionsKeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", " "),
- key.WithHelp("enter/space", "select session"),
- ),
-}
-
-func (i *sessionsCmp) Init() tea.Cmd {
- existing, err := i.app.Sessions.List(context.Background())
- if err != nil {
- return util.ReportError(err)
- }
- if len(existing) == 0 || existing[0].MessageCount > 0 {
- newSession, err := i.app.Sessions.Create(
- context.Background(),
- "New Session",
- )
- if err != nil {
- return util.ReportError(err)
- }
- existing = append([]session.Session{newSession}, existing...)
- }
- return tea.Batch(
- util.CmdHandler(InsertSessionsMsg{existing}),
- util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
- )
-}
-
-func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case InsertSessionsMsg:
- items := make([]list.Item, len(msg.sessions))
- for i, s := range msg.sessions {
- items[i] = listItem{
- id: s.ID,
- title: s.Title,
- desc: formatTokensAndCost(s.PromptTokens+s.CompletionTokens, s.Cost),
- }
- }
- return i, i.list.SetItems(items)
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
- // Check if the session is already in the list
- items := i.list.Items()
- for _, item := range items {
- s := item.(listItem)
- if s.id == msg.Payload.ID {
- return i, nil
- }
- }
- // insert the new session at the top of the list
- items = append([]list.Item{listItem{
- id: msg.Payload.ID,
- title: msg.Payload.Title,
- desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
- }}, items...)
- return i, i.list.SetItems(items)
- } else if msg.Type == pubsub.UpdatedEvent {
- // update the session in the list
- items := i.list.Items()
- for idx, item := range items {
- s := item.(listItem)
- if s.id == msg.Payload.ID {
- s.title = msg.Payload.Title
- s.desc = formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost)
- items[idx] = s
- break
- }
- }
- return i, i.list.SetItems(items)
- }
-
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, sessionKeyMapValue.Select):
- selected := i.list.SelectedItem()
- if selected == nil {
- return i, nil
- }
- return i, util.CmdHandler(SelectedSessionMsg{selected.(listItem).id})
- }
- }
- if i.focused {
- u, cmd := i.list.Update(msg)
- i.list = u
- return i, cmd
- }
- return i, nil
-}
-
-func (i *sessionsCmp) View() string {
- return i.list.View()
-}
-
-func (i *sessionsCmp) Blur() tea.Cmd {
- i.focused = false
- return nil
-}
-
-func (i *sessionsCmp) Focus() tea.Cmd {
- i.focused = true
- return nil
-}
-
-func (i *sessionsCmp) GetSize() (int, int) {
- return i.list.Width(), i.list.Height()
-}
-
-func (i *sessionsCmp) IsFocused() bool {
- return i.focused
-}
-
-func (i *sessionsCmp) SetSize(width int, height int) {
- i.list.SetSize(width, height)
-}
-
-func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
- totalCount := len(i.list.Items())
- itemsPerPage := i.list.Paginator.PerPage
- currentPage := i.list.Paginator.Page
-
- current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
-
- pageInfo := fmt.Sprintf(
- "%d-%d of %d",
- currentPage*itemsPerPage+1,
- current,
- totalCount,
- )
-
- title := "Sessions"
- if i.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- return map[layout.BorderPosition]string{
- layout.TopMiddleBorder: title,
- layout.BottomMiddleBorder: pageInfo,
- }
-}
-
-func (i *sessionsCmp) BindingKeys() []key.Binding {
- return append(layout.KeyMapToSlice(i.list.KeyMap), sessionKeyMapValue.Select)
-}
-
-func formatTokensAndCost(tokens int64, cost float64) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", tokens)
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- // Format cost with $ symbol and 2 decimal places
- formattedCost := fmt.Sprintf("$%.2f", cost)
-
- return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
-}
-
-func NewSessionsCmp(app *app.App) SessionsCmp {
- listDelegate := list.NewDefaultDelegate()
- defaultItemStyle := list.NewDefaultItemStyles()
- defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
- defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
-
- defaultStyle := list.DefaultStyles()
- defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
- defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
-
- listDelegate.Styles = defaultItemStyle
-
- listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
- listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
- listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
- listComponent.SetShowTitle(false)
- listComponent.SetShowPagination(false)
- listComponent.SetShowHelp(false)
- listComponent.SetShowStatusBar(false)
- listComponent.DisableQuitKeybindings()
-
- return &sessionsCmp{
- app: app,
- list: listComponent,
- focused: false,
- }
-}