1package tui
2
3import (
4 "fmt"
5 "log"
6 "os"
7 "path/filepath"
8
9 "github.com/charmbracelet/bubbles/key"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
12 "github.com/kujtimiihoxha/termai/internal/app"
13 "github.com/kujtimiihoxha/termai/internal/llm"
14 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/pubsub"
16 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
17 "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
18 "github.com/kujtimiihoxha/termai/internal/tui/layout"
19 "github.com/kujtimiihoxha/termai/internal/tui/page"
20 "github.com/kujtimiihoxha/termai/internal/tui/util"
21 "github.com/kujtimiihoxha/vimtea"
22)
23
24type keyMap struct {
25 Logs key.Binding
26 Return key.Binding
27 Back key.Binding
28 Quit key.Binding
29 Help key.Binding
30}
31
32var keys = keyMap{
33 Logs: key.NewBinding(
34 key.WithKeys("L"),
35 key.WithHelp("L", "logs"),
36 ),
37 Return: key.NewBinding(
38 key.WithKeys("esc"),
39 key.WithHelp("esc", "close"),
40 ),
41 Back: key.NewBinding(
42 key.WithKeys("backspace"),
43 key.WithHelp("backspace", "back"),
44 ),
45 Quit: key.NewBinding(
46 key.WithKeys("ctrl+c", "q"),
47 key.WithHelp("ctrl+c/q", "quit"),
48 ),
49 Help: key.NewBinding(
50 key.WithKeys("?"),
51 key.WithHelp("?", "toggle help"),
52 ),
53}
54
55var editorKeyMap = key.NewBinding(
56 key.WithKeys("i"),
57 key.WithHelp("i", "insert mode"),
58)
59
60type appModel struct {
61 width, height int
62 currentPage page.PageID
63 previousPage page.PageID
64 pages map[page.PageID]tea.Model
65 loadedPages map[page.PageID]bool
66 status tea.Model
67 help core.HelpCmp
68 dialog core.DialogCmp
69 dialogVisible bool
70 editorMode vimtea.EditorMode
71 showHelp bool
72}
73
74func (a appModel) Init() tea.Cmd {
75 cmd := a.pages[a.currentPage].Init()
76 a.loadedPages[a.currentPage] = true
77 return cmd
78}
79
80func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81 switch msg := msg.(type) {
82 case pubsub.Event[llm.AgentEvent]:
83 log.Println("AgentEvent")
84 log.Println(msg)
85 case pubsub.Event[permission.PermissionRequest]:
86 return a, dialog.NewPermissionDialogCmd(
87 msg.Payload,
88 fmt.Sprintf(
89 "Tool: %s\nAction: %s\nParams: %v",
90 msg.Payload.ToolName,
91 msg.Payload.Action,
92 msg.Payload.Params,
93 ),
94 )
95 case dialog.PermissionResponseMsg:
96 switch msg.Action {
97 case dialog.PermissionAllow:
98 permission.Default.Grant(msg.Permission)
99 case dialog.PermissionAllowForSession:
100 permission.Default.GrantPersistant(msg.Permission)
101 case dialog.PermissionDeny:
102 permission.Default.Deny(msg.Permission)
103 }
104 case vimtea.EditorModeMsg:
105 a.editorMode = msg.Mode
106 case tea.WindowSizeMsg:
107 msg.Height -= 1 // Make space for the status bar
108 a.width, a.height = msg.Width, msg.Height
109
110 a.status, _ = a.status.Update(msg)
111
112 uh, _ := a.help.Update(msg)
113 a.help = uh.(core.HelpCmp)
114
115 p, cmd := a.pages[a.currentPage].Update(msg)
116 a.pages[a.currentPage] = p
117 return a, cmd
118 case core.DialogMsg:
119 d, cmd := a.dialog.Update(msg)
120 a.dialog = d.(core.DialogCmp)
121 a.dialogVisible = true
122 return a, cmd
123 case core.DialogCloseMsg:
124 d, cmd := a.dialog.Update(msg)
125 a.dialog = d.(core.DialogCmp)
126 a.dialogVisible = false
127 return a, cmd
128 case page.PageChangeMsg:
129 return a, a.moveToPage(msg.ID)
130 case util.InfoMsg:
131 a.status, _ = a.status.Update(msg)
132 case util.ErrorMsg:
133 a.status, _ = a.status.Update(msg)
134 case tea.KeyMsg:
135 if a.editorMode == vimtea.ModeNormal {
136 switch {
137 case key.Matches(msg, keys.Quit):
138 return a, dialog.NewQuitDialogCmd()
139 case key.Matches(msg, keys.Back):
140 if a.previousPage != "" {
141 return a, a.moveToPage(a.previousPage)
142 }
143 case key.Matches(msg, keys.Return):
144 if a.showHelp {
145 a.ToggleHelp()
146 return a, nil
147 }
148 case key.Matches(msg, keys.Logs):
149 return a, a.moveToPage(page.LogsPage)
150 case key.Matches(msg, keys.Help):
151 a.ToggleHelp()
152 return a, nil
153 }
154 }
155 }
156 if a.dialogVisible {
157 d, cmd := a.dialog.Update(msg)
158 a.dialog = d.(core.DialogCmp)
159 return a, cmd
160 }
161 p, cmd := a.pages[a.currentPage].Update(msg)
162 a.pages[a.currentPage] = p
163 return a, cmd
164}
165
166func (a *appModel) ToggleHelp() {
167 if a.showHelp {
168 a.showHelp = false
169 a.height += a.help.Height()
170 } else {
171 a.showHelp = true
172 a.height -= a.help.Height()
173 }
174
175 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
176 sizable.SetSize(a.width, a.height)
177 }
178}
179
180func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
181 var cmd tea.Cmd
182 if _, ok := a.loadedPages[pageID]; !ok {
183 cmd = a.pages[pageID].Init()
184 a.loadedPages[pageID] = true
185 }
186 a.previousPage = a.currentPage
187 a.currentPage = pageID
188 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
189 sizable.SetSize(a.width, a.height)
190 }
191
192 return cmd
193}
194
195func (a appModel) View() string {
196 components := []string{
197 a.pages[a.currentPage].View(),
198 }
199
200 if a.showHelp {
201 bindings := layout.KeyMapToSlice(keys)
202 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
203 bindings = append(bindings, p.BindingKeys()...)
204 }
205 if a.dialogVisible {
206 bindings = append(bindings, a.dialog.BindingKeys()...)
207 }
208 a.help.SetBindings(bindings)
209 components = append(components, a.help.View())
210 }
211
212 components = append(components, a.status.View())
213
214 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
215
216 if a.dialogVisible {
217 overlay := a.dialog.View()
218 row := lipgloss.Height(appView) / 2
219 row -= lipgloss.Height(overlay) / 2
220 col := lipgloss.Width(appView) / 2
221 col -= lipgloss.Width(overlay) / 2
222 appView = layout.PlaceOverlay(
223 col,
224 row,
225 overlay,
226 appView,
227 true,
228 )
229 }
230 return appView
231}
232
233func New(app *app.App) tea.Model {
234 // Check if config file exists, if not, start with init page
235 homedir, _ := os.UserHomeDir()
236 configPath := filepath.Join(homedir, ".termai.yaml")
237
238 startPage := page.ReplPage
239 if _, err := os.Stat(configPath); os.IsNotExist(err) {
240 startPage = page.InitPage
241 }
242
243 return &appModel{
244 currentPage: startPage,
245 loadedPages: make(map[page.PageID]bool),
246 status: core.NewStatusCmp(),
247 help: core.NewHelpCmp(),
248 dialog: core.NewDialogCmp(),
249 pages: map[page.PageID]tea.Model{
250 page.LogsPage: page.NewLogsPage(),
251 page.InitPage: page.NewInitPage(),
252 page.ReplPage: page.NewReplPage(app),
253 },
254 }
255}