mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-27 12:44:22 +01:00
reimplement agent,provider and add file history
This commit is contained in:
@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
|
||||
|
||||
type SessionClearedMsg struct{}
|
||||
|
||||
type AgentWorkingMsg bool
|
||||
|
||||
type EditorFocusMsg bool
|
||||
|
||||
func lspsConfigured(width int) string {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), ¶ms)
|
||||
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), ¶ms)
|
||||
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), ¶ms)
|
||||
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), ¶ms)
|
||||
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), ¶ms)
|
||||
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), ¶ms)
|
||||
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), ¶ms)
|
||||
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)),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
182
internal/tui/components/dialog/help.go
Normal file
182
internal/tui/components/dialog/help.go
Normal file
@@ -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{}
|
||||
}
|
||||
@@ -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...))
|
||||
return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
|
||||
}
|
||||
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
|
||||
glamour.WithWordWrap(p.width-10),
|
||||
glamour.WithEmoji(),
|
||||
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)
|
||||
}
|
||||
|
||||
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(" "),
|
||||
)
|
||||
|
||||
// Handle different tool types
|
||||
remainingWidth := p.width - lipgloss.Width(content)
|
||||
if remainingWidth > 0 {
|
||||
content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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:
|
||||
pr := p.permission.Params.(tools.BashPermissionsParams)
|
||||
headerParts = append(headerParts, keyStyle.Render("Command:"))
|
||||
headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
|
||||
case tools.EditToolName:
|
||||
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"))
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) renderBashContent() string {
|
||||
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
|
||||
content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
|
||||
|
||||
renderedContent, _ := r.Render(content)
|
||||
p.contentViewPort.Width = p.width - 2 - 2
|
||||
// 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
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
// Style the viewport
|
||||
var contentBorder lipgloss.Border
|
||||
var borderColor lipgloss.TerminalColor
|
||||
|
||||
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)
|
||||
|
||||
if p.isViewportFocus {
|
||||
contentStyle = contentStyle.BorderBackground(styles.Surface0)
|
||||
}
|
||||
|
||||
contentFinal := contentStyle.Render(p.contentViewPort.View())
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
headerContent,
|
||||
contentFinal,
|
||||
form,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// Style the viewport
|
||||
var contentBorder lipgloss.Border
|
||||
var borderColor lipgloss.TerminalColor
|
||||
|
||||
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)
|
||||
|
||||
if p.isViewportFocus {
|
||||
contentStyle = contentStyle.BorderBackground(styles.Surface0)
|
||||
}
|
||||
|
||||
contentFinal := contentStyle.Render(p.contentViewPort.View())
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
headerContent,
|
||||
contentFinal,
|
||||
form,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// Style the viewport
|
||||
var contentBorder lipgloss.Border
|
||||
var borderColor lipgloss.TerminalColor
|
||||
|
||||
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)
|
||||
|
||||
if p.isViewportFocus {
|
||||
contentStyle = contentStyle.BorderBackground(styles.Surface0)
|
||||
}
|
||||
|
||||
contentFinal := contentStyle.Render(p.contentViewPort.View())
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
headerContent,
|
||||
contentFinal,
|
||||
form,
|
||||
)
|
||||
|
||||
case tools.FetchToolName:
|
||||
pr := p.permission.Params.(tools.FetchPermissionsParams)
|
||||
headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
|
||||
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 := contentStyle.Render(p.contentViewPort.View())
|
||||
if renderedContent == "" {
|
||||
contentFinal = ""
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
headerContent,
|
||||
contentFinal,
|
||||
form,
|
||||
)
|
||||
|
||||
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 := contentStyle.Render(p.contentViewPort.View())
|
||||
if renderedContent == "" {
|
||||
contentFinal = ""
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
headerContent,
|
||||
contentFinal,
|
||||
form,
|
||||
)
|
||||
finalContent := styles.BaseStyle.
|
||||
Width(p.contentViewPort.Width).
|
||||
Render(renderedContent)
|
||||
p.contentViewPort.SetContent(finalContent)
|
||||
return p.styleViewport()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
p.contentViewPort.SetContent(diff)
|
||||
return p.styleViewport()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
p.contentViewPort.SetContent(diff)
|
||||
return p.styleViewport()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) renderFetchContent() string {
|
||||
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
|
||||
content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
p.contentViewPort.SetContent(renderedContent)
|
||||
return p.styleViewport()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) renderDefaultContent() string {
|
||||
content := p.permission.Description
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
p.contentViewPort.SetContent(renderedContent)
|
||||
|
||||
if renderedContent == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.styleViewport()
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) styleViewport() string {
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Background(styles.Background)
|
||||
|
||||
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:
|
||||
contentFinal = p.renderDefaultContent()
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
title,
|
||||
styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
|
||||
headerContent,
|
||||
contentFinal,
|
||||
buttons,
|
||||
)
|
||||
|
||||
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) SetSize(width int, height int) {
|
||||
p.width = width
|
||||
p.height = height
|
||||
p.form = p.form.WithWidth(width)
|
||||
}
|
||||
|
||||
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
|
||||
return p.form.KeyBinds()
|
||||
return layout.KeyMapToSlice(helpKeys)
|
||||
}
|
||||
|
||||
func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
|
||||
// Create a note field for displaying the content
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
|
||||
p.permission = permission
|
||||
p.SetSize()
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
theme := styles.HuhTheme()
|
||||
// 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
|
||||
}
|
||||
|
||||
// Setup form width and height
|
||||
form := huh.NewForm(huh.NewGroup(selectOption)).
|
||||
WithShowHelp(false).
|
||||
WithTheme(theme).
|
||||
WithShowErrors(false)
|
||||
content, err := generator()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error formatting diff: %v", err)
|
||||
}
|
||||
|
||||
// Focus the form for immediate interaction
|
||||
selectOption.Focus()
|
||||
c.diffCache[key] = content
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
content, err := generator()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error rendering markdown: %v", err)
|
||||
}
|
||||
|
||||
c.markdownCache[key] = content
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func NewPermissionDialogCmp() PermissionDialogCmp {
|
||||
// Create viewport for content
|
||||
contentViewport := viewport.New(0, 0)
|
||||
|
||||
return &permissionDialogCmp{
|
||||
permission: permission,
|
||||
form: form,
|
||||
selectOption: selectOption,
|
||||
contentViewPort: contentViewport,
|
||||
selectedOption: 0, // Default to "Allow"
|
||||
diffCache: make(map[string]string),
|
||||
markdownCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ",
|
||||
}),
|
||||
)
|
||||
|
||||
// Focus the dialog
|
||||
dialogPane.Focus()
|
||||
widthRatio := 0.7
|
||||
heightRatio := 0.6
|
||||
minWidth := 100
|
||||
minHeight := 30
|
||||
|
||||
// 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
|
||||
}
|
||||
// Return the dialog command
|
||||
return util.CmdHandler(core.DialogMsg{
|
||||
Content: dialogPane,
|
||||
WidthRatio: widthRatio,
|
||||
HeightRatio: heightRatio,
|
||||
MinWidth: minWidth,
|
||||
MinHeight: minHeight,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
func (q *quitDialogCmp) GetSize() (int, int) {
|
||||
return q.width, q.height
|
||||
}
|
||||
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) SetSize(width int, height int) {
|
||||
q.width = width
|
||||
q.height = height
|
||||
q.form = q.form.WithWidth(width).WithHeight(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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,8 @@ import (
|
||||
|
||||
type DetailComponent interface {
|
||||
tea.Model
|
||||
layout.Focusable
|
||||
layout.Sizeable
|
||||
layout.Bindings
|
||||
layout.Bordered
|
||||
}
|
||||
|
||||
type detailCmp struct {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/ansi"
|
||||
@@ -45,13 +46,15 @@ func PlaceOverlay(
|
||||
if shadow {
|
||||
var shadowbg string = ""
|
||||
shadowchar := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#333333")).
|
||||
Background(styles.BackgroundDarker).
|
||||
Foreground(styles.Background).
|
||||
Render("░")
|
||||
bgchar := styles.BaseStyle.Render(" ")
|
||||
for i := 0; i <= fgHeight; i++ {
|
||||
if i == 0 {
|
||||
shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
|
||||
shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
|
||||
} else {
|
||||
shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
|
||||
shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +162,6 @@ func max(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
|
||||
type whitespace struct {
|
||||
style termenv.Style
|
||||
chars string
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
type SplitPaneLayout interface {
|
||||
tea.Model
|
||||
Sizeable
|
||||
Bindings
|
||||
SetLeftPanel(panel Container)
|
||||
SetRightPanel(panel Container)
|
||||
SetBottomPanel(panel Container)
|
||||
|
||||
@@ -37,7 +37,6 @@ var keyMap = ChatKeyMap{
|
||||
}
|
||||
|
||||
func (p *chatPage) Init() tea.Cmd {
|
||||
// TODO: remove
|
||||
cmds := []tea.Cmd{
|
||||
p.layout.Init(),
|
||||
}
|
||||
@@ -48,9 +47,7 @@ func (p *chatPage) Init() tea.Cmd {
|
||||
cmd := p.setSidebar()
|
||||
cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(p.session)), cmd)
|
||||
}
|
||||
return tea.Batch(
|
||||
cmds...,
|
||||
)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -68,6 +65,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
p.session = session.Session{}
|
||||
p.clearSidebar()
|
||||
return p, util.CmdHandler(chat.SessionClearedMsg{})
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.session.ID != "" {
|
||||
// Cancel the current session's generation process
|
||||
// This allows users to interrupt long-running operations
|
||||
p.app.CoderAgent.Cancel(p.session.ID)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
u, cmd := p.layout.Update(msg)
|
||||
@@ -80,7 +84,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (p *chatPage) setSidebar() tea.Cmd {
|
||||
sidebarContainer := layout.NewContainer(
|
||||
chat.NewSidebarCmp(p.session),
|
||||
chat.NewSidebarCmp(p.session, p.app.History),
|
||||
layout.WithPadding(1, 1, 1, 1),
|
||||
)
|
||||
p.layout.SetRightPanel(sidebarContainer)
|
||||
@@ -111,14 +115,28 @@ func (p *chatPage) sendMessage(text string) tea.Cmd {
|
||||
cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
p.app.CoderAgent.Generate(context.Background(), p.session.ID, text)
|
||||
p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) SetSize(width, height int) {
|
||||
p.layout.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (p *chatPage) GetSize() (int, int) {
|
||||
return p.layout.GetSize()
|
||||
}
|
||||
|
||||
func (p *chatPage) View() string {
|
||||
return p.layout.View()
|
||||
}
|
||||
|
||||
func (p *chatPage) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(keyMap)
|
||||
bindings = append(bindings, p.layout.BindingKeys()...)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) tea.Model {
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesCmp(app),
|
||||
@@ -126,7 +144,7 @@ func NewChatPage(app *app.App) tea.Model {
|
||||
)
|
||||
|
||||
editorContainer := layout.NewContainer(
|
||||
chat.NewEditorCmp(),
|
||||
chat.NewEditorCmp(app),
|
||||
layout.WithBorder(true, false, false, false),
|
||||
)
|
||||
return &chatPage{
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/llm/models"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var InitPage PageID = "init"
|
||||
|
||||
type configSaved struct{}
|
||||
|
||||
type initPage struct {
|
||||
form *huh.Form
|
||||
width int
|
||||
height int
|
||||
saved bool
|
||||
errorMsg string
|
||||
statusMsg string
|
||||
modelOpts []huh.Option[string]
|
||||
bigModel string
|
||||
smallModel string
|
||||
openAIKey string
|
||||
anthropicKey string
|
||||
groqKey string
|
||||
maxTokens string
|
||||
dataDir string
|
||||
agent string
|
||||
}
|
||||
|
||||
func (i *initPage) Init() tea.Cmd {
|
||||
return i.form.Init()
|
||||
}
|
||||
|
||||
func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
i.width = msg.Width - 4 // Account for border
|
||||
i.height = msg.Height - 4
|
||||
i.form = i.form.WithWidth(i.width).WithHeight(i.height)
|
||||
return i, nil
|
||||
|
||||
case configSaved:
|
||||
i.saved = true
|
||||
i.statusMsg = "Configuration saved successfully. Press any key to continue."
|
||||
return i, nil
|
||||
}
|
||||
|
||||
if i.saved {
|
||||
switch msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Process the form
|
||||
form, cmd := i.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
i.form = f
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if i.form.State == huh.StateCompleted {
|
||||
// Save configuration to file
|
||||
configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
|
||||
maxTokens, _ := strconv.Atoi(i.maxTokens)
|
||||
config := map[string]any{
|
||||
"models": map[string]string{
|
||||
"big": i.bigModel,
|
||||
"small": i.smallModel,
|
||||
},
|
||||
"providers": map[string]any{
|
||||
"openai": map[string]string{
|
||||
"key": i.openAIKey,
|
||||
},
|
||||
"anthropic": map[string]string{
|
||||
"key": i.anthropicKey,
|
||||
},
|
||||
"groq": map[string]string{
|
||||
"key": i.groqKey,
|
||||
},
|
||||
"common": map[string]int{
|
||||
"max_tokens": maxTokens,
|
||||
},
|
||||
},
|
||||
"data": map[string]string{
|
||||
"dir": i.dataDir,
|
||||
},
|
||||
"agents": map[string]string{
|
||||
"default": i.agent,
|
||||
},
|
||||
"log": map[string]string{
|
||||
"level": "info",
|
||||
},
|
||||
}
|
||||
|
||||
// Write config to viper
|
||||
for k, v := range config {
|
||||
viper.Set(k, v)
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
err := viper.WriteConfigAs(configPath)
|
||||
if err != nil {
|
||||
i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Return to main page
|
||||
return i, util.CmdHandler(configSaved{})
|
||||
}
|
||||
|
||||
return i, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (i *initPage) View() string {
|
||||
if i.saved {
|
||||
return lipgloss.NewStyle().
|
||||
Width(i.width).
|
||||
Height(i.height).
|
||||
Align(lipgloss.Center, lipgloss.Center).
|
||||
Render(lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
|
||||
"",
|
||||
lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
|
||||
))
|
||||
}
|
||||
|
||||
view := i.form.View()
|
||||
if i.errorMsg != "" {
|
||||
errorBox := lipgloss.NewStyle().
|
||||
Padding(1).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(styles.Red).
|
||||
Width(i.width - 4).
|
||||
Render(i.errorMsg)
|
||||
view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (i *initPage) GetSize() (int, int) {
|
||||
return i.width, i.height
|
||||
}
|
||||
|
||||
func (i *initPage) SetSize(width int, height int) {
|
||||
i.width = width
|
||||
i.height = height
|
||||
i.form = i.form.WithWidth(width).WithHeight(height)
|
||||
}
|
||||
|
||||
func (i *initPage) BindingKeys() []key.Binding {
|
||||
if i.saved {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("enter", "space", "esc"),
|
||||
key.WithHelp("any key", "continue"),
|
||||
),
|
||||
}
|
||||
}
|
||||
return i.form.KeyBinds()
|
||||
}
|
||||
|
||||
func NewInitPage() tea.Model {
|
||||
// Create model options
|
||||
var modelOpts []huh.Option[string]
|
||||
for id, model := range models.SupportedModels {
|
||||
modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
|
||||
}
|
||||
|
||||
// Create agent options
|
||||
agentOpts := []huh.Option[string]{
|
||||
huh.NewOption("Coder", "coder"),
|
||||
huh.NewOption("Assistant", "assistant"),
|
||||
}
|
||||
|
||||
// Init page with form
|
||||
initModel := &initPage{
|
||||
modelOpts: modelOpts,
|
||||
bigModel: string(models.Claude37Sonnet),
|
||||
smallModel: string(models.Claude37Sonnet),
|
||||
maxTokens: "4000",
|
||||
dataDir: ".termai",
|
||||
agent: "coder",
|
||||
}
|
||||
|
||||
// API Keys group
|
||||
apiKeysGroup := huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("API Keys").
|
||||
Description("You need to provide at least one API key to use termai"),
|
||||
|
||||
huh.NewInput().
|
||||
Title("OpenAI API Key").
|
||||
Placeholder("sk-...").
|
||||
Key("openai_key").
|
||||
Value(&initModel.openAIKey),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Anthropic API Key").
|
||||
Placeholder("sk-ant-...").
|
||||
Key("anthropic_key").
|
||||
Value(&initModel.anthropicKey),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Groq API Key").
|
||||
Placeholder("gsk_...").
|
||||
Key("groq_key").
|
||||
Value(&initModel.groqKey),
|
||||
)
|
||||
|
||||
// Model configuration group
|
||||
modelsGroup := huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Model Configuration").
|
||||
Description("Select which models to use"),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Big Model").
|
||||
Options(modelOpts...).
|
||||
Key("big_model").
|
||||
Value(&initModel.bigModel),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Small Model").
|
||||
Options(modelOpts...).
|
||||
Key("small_model").
|
||||
Value(&initModel.smallModel),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Max Tokens").
|
||||
Placeholder("4000").
|
||||
Key("max_tokens").
|
||||
CharLimit(5).
|
||||
Validate(func(s string) error {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("must be a positive number")
|
||||
}
|
||||
initModel.maxTokens = s
|
||||
return nil
|
||||
}).
|
||||
Value(&initModel.maxTokens),
|
||||
)
|
||||
|
||||
// General settings group
|
||||
generalGroup := huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("General Settings").
|
||||
Description("Configure general termai settings"),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Data Directory").
|
||||
Placeholder(".termai").
|
||||
Key("data_dir").
|
||||
Value(&initModel.dataDir),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Default Agent").
|
||||
Options(agentOpts...).
|
||||
Key("agent").
|
||||
Value(&initModel.agent),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Save Configuration").
|
||||
Affirmative("Save").
|
||||
Negative("Cancel"),
|
||||
)
|
||||
|
||||
// Create form with theme
|
||||
form := huh.NewForm(
|
||||
apiKeysGroup,
|
||||
modelsGroup,
|
||||
generalGroup,
|
||||
).WithTheme(styles.HuhTheme()).
|
||||
WithShowHelp(true).
|
||||
WithShowErrors(true)
|
||||
|
||||
// Set the form in the model
|
||||
initModel.form = form
|
||||
|
||||
return layout.NewSinglePane(
|
||||
initModel,
|
||||
layout.WithSinglePaneFocusable(true),
|
||||
layout.WithSinglePaneBordered(true),
|
||||
layout.WithSinglePaneBorderText(
|
||||
map[layout.BorderPosition]string{
|
||||
layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,23 @@ import (
|
||||
|
||||
var LogsPage PageID = "logs"
|
||||
|
||||
type logsPage struct {
|
||||
table logs.TableComponent
|
||||
details logs.DetailComponent
|
||||
}
|
||||
|
||||
func (p *logsPage) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *logsPage) View() string {
|
||||
return p.table.View() + "\n" + p.details.View()
|
||||
}
|
||||
|
||||
func NewLogsPage() tea.Model {
|
||||
return layout.NewBentoLayout(
|
||||
layout.BentoPanes{
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/repl"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
)
|
||||
|
||||
var ReplPage PageID = "repl"
|
||||
|
||||
func NewReplPage(app *app.App) tea.Model {
|
||||
return layout.NewBentoLayout(
|
||||
layout.BentoPanes{
|
||||
layout.BentoLeftPane: repl.NewSessionsCmp(app),
|
||||
layout.BentoRightTopPane: repl.NewMessagesCmp(app),
|
||||
layout.BentoRightBottomPane: repl.NewEditorCmp(app),
|
||||
},
|
||||
layout.WithBentoLayoutCurrentPane(layout.BentoRightBottomPane),
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -12,47 +10,41 @@ import (
|
||||
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/repl"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/page"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/kujtimiihoxha/vimtea"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
Logs key.Binding
|
||||
Return key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Logs key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Logs: key.NewBinding(
|
||||
key.WithKeys("L"),
|
||||
key.WithHelp("L", "logs"),
|
||||
),
|
||||
Return: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("backspace"),
|
||||
key.WithHelp("backspace", "back"),
|
||||
key.WithKeys("ctrl+l"),
|
||||
key.WithHelp("ctrl+L", "logs"),
|
||||
),
|
||||
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("ctrl+c", "q"),
|
||||
key.WithHelp("ctrl+c/q", "quit"),
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "quit"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
key.WithKeys("ctrl+_"),
|
||||
key.WithHelp("ctrl+?", "toggle help"),
|
||||
),
|
||||
}
|
||||
|
||||
var replKeyMap = key.NewBinding(
|
||||
key.WithKeys("N"),
|
||||
key.WithHelp("N", "new session"),
|
||||
var returnKey = key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
)
|
||||
|
||||
var logsKeyReturnKey = key.NewBinding(
|
||||
key.WithKeys("backspace"),
|
||||
key.WithHelp("backspace", "go back"),
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
@@ -62,18 +54,30 @@ type appModel struct {
|
||||
pages map[page.PageID]tea.Model
|
||||
loadedPages map[page.PageID]bool
|
||||
status tea.Model
|
||||
help core.HelpCmp
|
||||
dialog core.DialogCmp
|
||||
app *app.App
|
||||
dialogVisible bool
|
||||
editorMode vimtea.EditorMode
|
||||
showHelp bool
|
||||
|
||||
showPermissions bool
|
||||
permissions dialog.PermissionDialogCmp
|
||||
|
||||
showHelp bool
|
||||
help dialog.HelpCmp
|
||||
|
||||
showQuit bool
|
||||
quit dialog.QuitDialog
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmd := a.pages[a.currentPage].Init()
|
||||
a.loadedPages[a.currentPage] = true
|
||||
return cmd
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.status.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.quit.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
cmd = a.help.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -81,22 +85,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
var cmds []tea.Cmd
|
||||
msg.Height -= 1 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
||||
a.status, _ = a.status.Update(msg)
|
||||
|
||||
uh, _ := a.help.Update(msg)
|
||||
a.help = uh.(core.HelpCmp)
|
||||
|
||||
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.pages[a.currentPage] = p
|
||||
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
prm, permCmd := a.permissions.Update(msg)
|
||||
a.permissions = prm.(dialog.PermissionDialogCmp)
|
||||
cmds = append(cmds, permCmd)
|
||||
|
||||
help, helpCmd := a.help.Update(msg)
|
||||
a.help = help.(dialog.HelpCmp)
|
||||
cmds = append(cmds, helpCmd)
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
@@ -141,7 +143,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Permission
|
||||
case pubsub.Event[permission.PermissionRequest]:
|
||||
return a, dialog.NewPermissionDialogCmd(msg.Payload)
|
||||
a.showPermissions = true
|
||||
a.permissions.SetPermissions(msg.Payload)
|
||||
return a, nil
|
||||
case dialog.PermissionResponseMsg:
|
||||
switch msg.Action {
|
||||
case dialog.PermissionAllow:
|
||||
@@ -151,91 +155,71 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case dialog.PermissionDeny:
|
||||
a.app.Permissions.Deny(msg.Permission)
|
||||
}
|
||||
|
||||
// Dialog
|
||||
case core.DialogMsg:
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
a.dialogVisible = true
|
||||
return a, cmd
|
||||
case core.DialogCloseMsg:
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
a.dialogVisible = false
|
||||
return a, cmd
|
||||
|
||||
// Editor
|
||||
case vimtea.EditorModeMsg:
|
||||
a.editorMode = msg.Mode
|
||||
a.showPermissions = false
|
||||
return a, nil
|
||||
|
||||
case page.PageChangeMsg:
|
||||
return a, a.moveToPage(msg.ID)
|
||||
|
||||
case dialog.CloseQuitMsg:
|
||||
a.showQuit = false
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if a.editorMode == vimtea.ModeNormal {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, dialog.NewQuitDialogCmd()
|
||||
case key.Matches(msg, keys.Back):
|
||||
if a.previousPage != "" {
|
||||
return a, a.moveToPage(a.previousPage)
|
||||
}
|
||||
case key.Matches(msg, keys.Return):
|
||||
if a.showHelp {
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
case key.Matches(msg, replKeyMap):
|
||||
if a.currentPage == page.ReplPage {
|
||||
sessions, err := a.app.Sessions.List(context.Background())
|
||||
if err != nil {
|
||||
return a, util.CmdHandler(util.ReportError(err))
|
||||
}
|
||||
lastSession := sessions[0]
|
||||
if lastSession.MessageCount == 0 {
|
||||
return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
|
||||
}
|
||||
s, err := a.app.Sessions.Create(context.Background(), "New Session")
|
||||
if err != nil {
|
||||
return a, util.CmdHandler(util.ReportError(err))
|
||||
}
|
||||
return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
|
||||
}
|
||||
// case key.Matches(msg, keys.Logs):
|
||||
// return a, a.moveToPage(page.LogsPage)
|
||||
case msg.String() == "O":
|
||||
return a, a.moveToPage(page.ReplPage)
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.ToggleHelp()
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
a.showQuit = !a.showQuit
|
||||
if a.showHelp {
|
||||
a.showHelp = false
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, logsKeyReturnKey):
|
||||
if a.currentPage == page.LogsPage {
|
||||
return a, a.moveToPage(page.ChatPage)
|
||||
}
|
||||
case key.Matches(msg, returnKey):
|
||||
if a.showQuit {
|
||||
a.showQuit = !a.showQuit
|
||||
return a, nil
|
||||
}
|
||||
if a.showHelp {
|
||||
a.showHelp = !a.showHelp
|
||||
return a, nil
|
||||
}
|
||||
case key.Matches(msg, keys.Logs):
|
||||
return a, a.moveToPage(page.LogsPage)
|
||||
case key.Matches(msg, keys.Help):
|
||||
if a.showQuit {
|
||||
return a, nil
|
||||
}
|
||||
a.showHelp = !a.showHelp
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.dialogVisible {
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
cmds = append(cmds, cmd)
|
||||
return a, tea.Batch(cmds...)
|
||||
if a.showQuit {
|
||||
q, quitCmd := a.quit.Update(msg)
|
||||
a.quit = q.(dialog.QuitDialog)
|
||||
cmds = append(cmds, quitCmd)
|
||||
// Only block key messages send all other messages down
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
if a.showPermissions {
|
||||
d, permissionsCmd := a.permissions.Update(msg)
|
||||
a.permissions = d.(dialog.PermissionDialogCmp)
|
||||
cmds = append(cmds, permissionsCmd)
|
||||
// Only block key messages send all other messages down
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *appModel) ToggleHelp() {
|
||||
if a.showHelp {
|
||||
a.showHelp = false
|
||||
a.height += a.help.Height()
|
||||
} else {
|
||||
a.showHelp = true
|
||||
a.height -= a.help.Height()
|
||||
}
|
||||
|
||||
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
|
||||
sizable.SetSize(a.width, a.height)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
@@ -256,27 +240,12 @@ func (a appModel) View() string {
|
||||
a.pages[a.currentPage].View(),
|
||||
}
|
||||
|
||||
if a.showHelp {
|
||||
bindings := layout.KeyMapToSlice(keys)
|
||||
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
|
||||
bindings = append(bindings, p.BindingKeys()...)
|
||||
}
|
||||
if a.dialogVisible {
|
||||
bindings = append(bindings, a.dialog.BindingKeys()...)
|
||||
}
|
||||
if a.currentPage == page.ReplPage {
|
||||
bindings = append(bindings, replKeyMap)
|
||||
}
|
||||
a.help.SetBindings(bindings)
|
||||
components = append(components, a.help.View())
|
||||
}
|
||||
|
||||
components = append(components, a.status.View())
|
||||
|
||||
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
|
||||
|
||||
if a.dialogVisible {
|
||||
overlay := a.dialog.View()
|
||||
if a.showPermissions {
|
||||
overlay := a.permissions.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
@@ -289,30 +258,66 @@ func (a appModel) View() string {
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showHelp {
|
||||
bindings := layout.KeyMapToSlice(keys)
|
||||
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
|
||||
bindings = append(bindings, p.BindingKeys()...)
|
||||
}
|
||||
if a.showPermissions {
|
||||
bindings = append(bindings, a.permissions.BindingKeys()...)
|
||||
}
|
||||
if a.currentPage == page.LogsPage {
|
||||
bindings = append(bindings, logsKeyReturnKey)
|
||||
}
|
||||
|
||||
a.help.SetBindings(bindings)
|
||||
|
||||
overlay := a.help.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
col -= lipgloss.Width(overlay) / 2
|
||||
appView = layout.PlaceOverlay(
|
||||
col,
|
||||
row,
|
||||
overlay,
|
||||
appView,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showQuit {
|
||||
overlay := a.quit.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
col -= lipgloss.Width(overlay) / 2
|
||||
appView = layout.PlaceOverlay(
|
||||
col,
|
||||
row,
|
||||
overlay,
|
||||
appView,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
return appView
|
||||
}
|
||||
|
||||
func New(app *app.App) tea.Model {
|
||||
// homedir, _ := os.UserHomeDir()
|
||||
// configPath := filepath.Join(homedir, ".termai.yaml")
|
||||
//
|
||||
startPage := page.ChatPage
|
||||
// if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// startPage = page.InitPage
|
||||
// }
|
||||
|
||||
return &appModel{
|
||||
currentPage: startPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(),
|
||||
help: core.NewHelpCmp(),
|
||||
dialog: core.NewDialogCmp(),
|
||||
status: core.NewStatusCmp(app.LSPClients),
|
||||
help: dialog.NewHelpCmp(),
|
||||
quit: dialog.NewQuitCmp(),
|
||||
permissions: dialog.NewPermissionDialogCmp(),
|
||||
app: app,
|
||||
pages: map[page.PageID]tea.Model{
|
||||
page.ChatPage: page.NewChatPage(app),
|
||||
page.LogsPage: page.NewLogsPage(),
|
||||
page.InitPage: page.NewInitPage(),
|
||||
page.ReplPage: page.NewReplPage(app),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user