reimplement agent,provider and add file history

This commit is contained in:
Kujtim Hoxha
2025-04-16 20:06:23 +02:00
parent 76b4065f17
commit bbfa60c787
73 changed files with 3742 additions and 4026 deletions

View File

@@ -19,8 +19,6 @@ type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
type AgentWorkingMsg bool
type EditorFocusMsg bool
func lspsConfigured(width int) string {

View File

@@ -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,
}
}

View File

@@ -6,7 +6,9 @@ import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -17,9 +19,11 @@ import (
"github.com/kujtimiihoxha/termai/internal/llm/agent"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/llm/tools"
"github.com/kujtimiihoxha/termai/internal/logging"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
)
@@ -32,6 +36,9 @@ const (
toolMessageType
)
// messagesTickMsg is a message sent by the timer to refresh messages
type messagesTickMsg time.Time
type uiMessage struct {
ID string
messageType uiMessageType
@@ -52,24 +59,34 @@ type messagesCmp struct {
renderer *glamour.TermRenderer
focusRenderer *glamour.TermRenderer
cachedContent map[string]string
agentWorking bool
spinner spinner.Model
needsRerender bool
lastViewport string
}
func (m *messagesCmp) Init() tea.Cmd {
return tea.Batch(m.viewport.Init())
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
}
func (m *messagesCmp) tickMessages() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return messagesTickMsg(t)
})
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case AgentWorkingMsg:
m.agentWorking = bool(msg)
if m.agentWorking {
cmds = append(cmds, m.spinner.Tick)
case messagesTickMsg:
// Refresh messages if we have an active session
if m.session.ID != "" {
messages, err := m.app.Messages.List(context.Background(), m.session.ID)
if err == nil {
m.messages = messages
m.needsRerender = true
}
}
// Continue ticking
cmds = append(cmds, m.tickMessages())
case EditorFocusMsg:
m.writingMode = bool(msg)
case SessionSelectedMsg:
@@ -84,6 +101,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.messages = make([]message.Message, 0)
m.currentMsgID = ""
m.needsRerender = true
m.cachedContent = make(map[string]string)
return m, nil
case tea.KeyMsg:
@@ -104,6 +122,12 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if !messageExists {
// If we have messages, ensure the previous last message is not cached
if len(m.messages) > 0 {
lastMsgID := m.messages[len(m.messages)-1].ID
delete(m.cachedContent, lastMsgID)
}
m.messages = append(m.messages, msg.Payload)
delete(m.cachedContent, m.currentMsgID)
m.currentMsgID = msg.Payload.ID
@@ -112,36 +136,40 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
for _, v := range m.messages {
for _, c := range v.ToolCalls() {
// the message is being added to the session of a tool called
if c.ID == msg.Payload.SessionID {
m.needsRerender = true
}
}
}
} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
logging.Debug("Message", "finish", msg.Payload.FinishReason())
for i, v := range m.messages {
if v.ID == msg.Payload.ID {
if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
}
m.messages[i] = msg.Payload
delete(m.cachedContent, msg.Payload.ID)
// If this is the last message, ensure it's not cached
if i == len(m.messages)-1 {
delete(m.cachedContent, msg.Payload.ID)
}
m.needsRerender = true
break
}
}
}
}
if m.agentWorking {
u, cmd := m.spinner.Update(msg)
m.spinner = u
cmds = append(cmds, cmd)
}
oldPos := m.viewport.YPosition
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
if m.needsRerender {
m.renderView()
if len(m.messages) > 0 {
@@ -157,10 +185,21 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *messagesCmp) IsAgentWorking() bool {
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
}
func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
if v, ok := m.cachedContent[msg.ID]; ok {
return v
// Check if this is the last message in the list
isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
// Only use cache for non-last messages
if !isLastMessage {
if v, ok := m.cachedContent[msg.ID]; ok {
return v
}
}
style := styles.BaseStyle.
Width(m.width).
BorderLeft(true).
@@ -191,7 +230,12 @@ func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) s
parts...,
),
)
m.cachedContent[msg.ID] = rendered
// Only cache if it's not the last message
if !isLastMessage {
m.cachedContent[msg.ID] = rendered
}
return rendered
}
@@ -207,32 +251,71 @@ func formatTimeDifference(unixTime1, unixTime2 int64) string {
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
for _, v := range m.messages {
for _, c := range v.ToolResults() {
if c.ToolCallID == callID {
return &c
}
}
}
return nil
}
func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
key := ""
value := ""
result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
response := m.findToolResponse(toolCall.ID)
if response != nil && response.IsError {
// Clean up error message for display by removing newlines
// This ensures error messages display properly in the UI
errMsg := strings.ReplaceAll(response.Content, "\n", " ")
result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
} else if response != nil {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
}
switch toolCall.Name {
// TODO: add result data to the tools
case agent.AgentToolName:
key = "Task"
var params agent.AgentParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Prompt
// TODO: handle nested calls
value = strings.ReplaceAll(params.Prompt, "\n", " ")
if response != nil && !response.IsError {
firstRow := strings.ReplaceAll(response.Content, "\n", " ")
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
}
case tools.BashToolName:
key = "Bash"
var params tools.BashParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Command
if response != nil && !response.IsError {
metadata := tools.BashResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
}
case tools.EditToolName:
key = "Edit"
var params tools.EditParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
if response != nil && !response.IsError {
metadata := tools.EditResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
}
case tools.FetchToolName:
key = "Fetch"
var params tools.FetchParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.URL
if response != nil && !response.IsError {
result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
}
case tools.GlobToolName:
key = "Glob"
var params tools.GlobParams
@@ -241,6 +324,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
if response != nil && !response.IsError {
metadata := tools.GlobResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
}
}
case tools.GrepToolName:
key = "Grep"
var params tools.GrepParams
@@ -249,19 +341,46 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
params.Path = "."
}
value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
if response != nil && !response.IsError {
metadata := tools.GrepResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
}
}
case tools.LSToolName:
key = "Ls"
key = "ls"
var params tools.LSParams
json.Unmarshal([]byte(toolCall.Input), &params)
if params.Path == "" {
params.Path = "."
}
value = params.Path
if response != nil && !response.IsError {
metadata := tools.LSResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
}
}
case tools.SourcegraphToolName:
key = "Sourcegraph"
var params tools.SourcegraphParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.Query
if response != nil && !response.IsError {
metadata := tools.SourcegraphResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
if metadata.Truncated {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
} else {
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
}
}
case tools.ViewToolName:
key = "View"
var params tools.ViewParams
@@ -272,6 +391,12 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
var params tools.WriteParams
json.Unmarshal([]byte(toolCall.Input), &params)
value = params.FilePath
if response != nil && !response.IsError {
metadata := tools.WriteResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
}
default:
key = toolCall.Name
var params map[string]any
@@ -300,14 +425,15 @@ func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) s
)
if !isNested {
value = valyeStyle.
Width(m.width - lipgloss.Width(keyValye) - 2).
Render(
ansi.Truncate(
value,
m.width-lipgloss.Width(keyValye)-2,
value+" ",
m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
"...",
),
)
value += result
} else {
keyValye = keyStyle.Render(
fmt.Sprintf(" └ %s: ", key),
@@ -409,6 +535,27 @@ func (m *messagesCmp) renderView() {
m.uiMessages = make([]uiMessage, 0)
pos := 0
// If we have messages, ensure the last message is not cached
// This ensures we always render the latest content for the most recent message
// which may be actively updating (e.g., during generation)
if len(m.messages) > 0 {
lastMsgID := m.messages[len(m.messages)-1].ID
delete(m.cachedContent, lastMsgID)
}
// Limit cache to 10 messages
if len(m.cachedContent) > 15 {
// Create a list of keys to delete (oldest messages first)
keys := make([]string, 0, len(m.cachedContent))
for k := range m.cachedContent {
keys = append(keys, k)
}
// Delete oldest messages until we have 10 or fewer
for i := 0; i < len(keys)-15; i++ {
delete(m.cachedContent, keys[i])
}
}
for _, v := range m.messages {
switch v.Role {
case message.User:
@@ -487,7 +634,7 @@ func (m *messagesCmp) View() string {
func (m *messagesCmp) help() string {
text := ""
if m.agentWorking {
if m.IsAgentWorking() {
text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
)
@@ -562,9 +709,15 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
m.messages = messages
m.currentMsgID = m.messages[len(m.messages)-1].ID
m.needsRerender = true
m.cachedContent = make(map[string]string)
return nil
}
func (m *messagesCmp) BindingKeys() []key.Binding {
bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
return bindings
}
func NewMessagesCmp(app *app.App) tea.Model {
focusRenderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.MarkdownTheme(true)),

View File

@@ -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,
}
}
}

View File

@@ -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{}
}

View File

@@ -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),
}
}

View File

@@ -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,
}
}

View 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{}
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -16,10 +16,8 @@ import (
type DetailComponent interface {
tea.Model
layout.Focusable
layout.Sizeable
layout.Bindings
layout.Bordered
}
type detailCmp struct {

View File

@@ -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()
}

View File

@@ -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,
}
}

View File

@@ -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),
}
}

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -10,6 +10,7 @@ import (
type SplitPaneLayout interface {
tea.Model
Sizeable
Bindings
SetLeftPanel(panel Container)
SetRightPanel(panel Container)
SetBottomPanel(panel Container)

View File

@@ -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{

View File

@@ -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",
},
),
)
}

View File

@@ -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{

View File

@@ -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),
)
}

View File

@@ -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),
},
}
}