tui.go 7.8 KB

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