tui.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. package tui
  2. import (
  3. "context"
  4. "github.com/charmbracelet/bubbles/key"
  5. tea "github.com/charmbracelet/bubbletea"
  6. "github.com/charmbracelet/lipgloss"
  7. "github.com/opencode-ai/opencode/internal/app"
  8. "github.com/opencode-ai/opencode/internal/config"
  9. "github.com/opencode-ai/opencode/internal/logging"
  10. "github.com/opencode-ai/opencode/internal/permission"
  11. "github.com/opencode-ai/opencode/internal/pubsub"
  12. "github.com/opencode-ai/opencode/internal/tui/components/chat"
  13. "github.com/opencode-ai/opencode/internal/tui/components/core"
  14. "github.com/opencode-ai/opencode/internal/tui/components/dialog"
  15. "github.com/opencode-ai/opencode/internal/tui/layout"
  16. "github.com/opencode-ai/opencode/internal/tui/page"
  17. "github.com/opencode-ai/opencode/internal/tui/util"
  18. )
  19. type keyMap struct {
  20. Logs key.Binding
  21. Quit key.Binding
  22. Help key.Binding
  23. SwitchSession key.Binding
  24. Commands key.Binding
  25. }
  26. var keys = keyMap{
  27. Logs: key.NewBinding(
  28. key.WithKeys("ctrl+l"),
  29. key.WithHelp("ctrl+l", "logs"),
  30. ),
  31. Quit: key.NewBinding(
  32. key.WithKeys("ctrl+c"),
  33. key.WithHelp("ctrl+c", "quit"),
  34. ),
  35. Help: key.NewBinding(
  36. key.WithKeys("ctrl+_"),
  37. key.WithHelp("ctrl+?", "toggle help"),
  38. ),
  39. SwitchSession: key.NewBinding(
  40. key.WithKeys("ctrl+a"),
  41. key.WithHelp("ctrl+a", "switch session"),
  42. ),
  43. Commands: key.NewBinding(
  44. key.WithKeys("ctrl+k"),
  45. key.WithHelp("ctrl+k", "commands"),
  46. ),
  47. }
  48. var helpEsc = key.NewBinding(
  49. key.WithKeys("?"),
  50. key.WithHelp("?", "toggle help"),
  51. )
  52. var returnKey = key.NewBinding(
  53. key.WithKeys("esc"),
  54. key.WithHelp("esc", "close"),
  55. )
  56. var logsKeyReturnKey = key.NewBinding(
  57. key.WithKeys("backspace", "q"),
  58. key.WithHelp("backspace/q", "go back"),
  59. )
  60. type 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 core.StatusCmp
  67. app *app.App
  68. showPermissions bool
  69. permissions dialog.PermissionDialogCmp
  70. showHelp bool
  71. help dialog.HelpCmp
  72. showQuit bool
  73. quit dialog.QuitDialog
  74. showSessionDialog bool
  75. sessionDialog dialog.SessionDialog
  76. showCommandDialog bool
  77. commandDialog dialog.CommandDialog
  78. commands []dialog.Command
  79. showInitDialog bool
  80. initDialog dialog.InitDialogCmp
  81. }
  82. func (a appModel) Init() tea.Cmd {
  83. var cmds []tea.Cmd
  84. cmd := a.pages[a.currentPage].Init()
  85. a.loadedPages[a.currentPage] = true
  86. cmds = append(cmds, cmd)
  87. cmd = a.status.Init()
  88. cmds = append(cmds, cmd)
  89. cmd = a.quit.Init()
  90. cmds = append(cmds, cmd)
  91. cmd = a.help.Init()
  92. cmds = append(cmds, cmd)
  93. cmd = a.sessionDialog.Init()
  94. cmds = append(cmds, cmd)
  95. cmd = a.commandDialog.Init()
  96. cmds = append(cmds, cmd)
  97. cmd = a.initDialog.Init()
  98. cmds = append(cmds, cmd)
  99. // Check if we should show the init dialog
  100. cmds = append(cmds, func() tea.Msg {
  101. shouldShow, err := config.ShouldShowInitDialog()
  102. if err != nil {
  103. return util.InfoMsg{
  104. Type: util.InfoTypeError,
  105. Msg: "Failed to check init status: " + err.Error(),
  106. }
  107. }
  108. return dialog.ShowInitDialogMsg{Show: shouldShow}
  109. })
  110. return tea.Batch(cmds...)
  111. }
  112. func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  113. var cmds []tea.Cmd
  114. var cmd tea.Cmd
  115. switch msg := msg.(type) {
  116. case tea.WindowSizeMsg:
  117. msg.Height -= 1 // Make space for the status bar
  118. a.width, a.height = msg.Width, msg.Height
  119. s, _ := a.status.Update(msg)
  120. a.status = s.(core.StatusCmp)
  121. a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
  122. cmds = append(cmds, cmd)
  123. prm, permCmd := a.permissions.Update(msg)
  124. a.permissions = prm.(dialog.PermissionDialogCmp)
  125. cmds = append(cmds, permCmd)
  126. help, helpCmd := a.help.Update(msg)
  127. a.help = help.(dialog.HelpCmp)
  128. cmds = append(cmds, helpCmd)
  129. session, sessionCmd := a.sessionDialog.Update(msg)
  130. a.sessionDialog = session.(dialog.SessionDialog)
  131. cmds = append(cmds, sessionCmd)
  132. command, commandCmd := a.commandDialog.Update(msg)
  133. a.commandDialog = command.(dialog.CommandDialog)
  134. cmds = append(cmds, commandCmd)
  135. a.initDialog.SetSize(msg.Width, msg.Height)
  136. return a, tea.Batch(cmds...)
  137. // Status
  138. case util.InfoMsg:
  139. s, cmd := a.status.Update(msg)
  140. a.status = s.(core.StatusCmp)
  141. cmds = append(cmds, cmd)
  142. return a, tea.Batch(cmds...)
  143. case pubsub.Event[logging.LogMessage]:
  144. if msg.Payload.Persist {
  145. switch msg.Payload.Level {
  146. case "error":
  147. s, cmd := a.status.Update(util.InfoMsg{
  148. Type: util.InfoTypeError,
  149. Msg: msg.Payload.Message,
  150. TTL: msg.Payload.PersistTime,
  151. })
  152. a.status = s.(core.StatusCmp)
  153. cmds = append(cmds, cmd)
  154. case "info":
  155. s, cmd := a.status.Update(util.InfoMsg{
  156. Type: util.InfoTypeInfo,
  157. Msg: msg.Payload.Message,
  158. TTL: msg.Payload.PersistTime,
  159. })
  160. a.status = s.(core.StatusCmp)
  161. cmds = append(cmds, cmd)
  162. case "warn":
  163. s, cmd := a.status.Update(util.InfoMsg{
  164. Type: util.InfoTypeWarn,
  165. Msg: msg.Payload.Message,
  166. TTL: msg.Payload.PersistTime,
  167. })
  168. a.status = s.(core.StatusCmp)
  169. cmds = append(cmds, cmd)
  170. default:
  171. s, cmd := a.status.Update(util.InfoMsg{
  172. Type: util.InfoTypeInfo,
  173. Msg: msg.Payload.Message,
  174. TTL: msg.Payload.PersistTime,
  175. })
  176. a.status = s.(core.StatusCmp)
  177. cmds = append(cmds, cmd)
  178. }
  179. }
  180. case util.ClearStatusMsg:
  181. s, _ := a.status.Update(msg)
  182. a.status = s.(core.StatusCmp)
  183. // Permission
  184. case pubsub.Event[permission.PermissionRequest]:
  185. a.showPermissions = true
  186. return a, a.permissions.SetPermissions(msg.Payload)
  187. case dialog.PermissionResponseMsg:
  188. var cmd tea.Cmd
  189. switch msg.Action {
  190. case dialog.PermissionAllow:
  191. a.app.Permissions.Grant(msg.Permission)
  192. case dialog.PermissionAllowForSession:
  193. a.app.Permissions.GrantPersistant(msg.Permission)
  194. case dialog.PermissionDeny:
  195. a.app.Permissions.Deny(msg.Permission)
  196. }
  197. a.showPermissions = false
  198. return a, cmd
  199. case page.PageChangeMsg:
  200. return a, a.moveToPage(msg.ID)
  201. case dialog.CloseQuitMsg:
  202. a.showQuit = false
  203. return a, nil
  204. case dialog.CloseSessionDialogMsg:
  205. a.showSessionDialog = false
  206. return a, nil
  207. case dialog.CloseCommandDialogMsg:
  208. a.showCommandDialog = false
  209. return a, nil
  210. case dialog.ShowInitDialogMsg:
  211. a.showInitDialog = msg.Show
  212. return a, nil
  213. case dialog.CloseInitDialogMsg:
  214. a.showInitDialog = false
  215. if msg.Initialize {
  216. // Run the initialization command
  217. for _, cmd := range a.commands {
  218. if cmd.ID == "init" {
  219. // Mark the project as initialized
  220. if err := config.MarkProjectInitialized(); err != nil {
  221. return a, util.ReportError(err)
  222. }
  223. return a, cmd.Handler(cmd)
  224. }
  225. }
  226. } else {
  227. // Mark the project as initialized without running the command
  228. if err := config.MarkProjectInitialized(); err != nil {
  229. return a, util.ReportError(err)
  230. }
  231. }
  232. return a, nil
  233. case chat.SessionSelectedMsg:
  234. a.sessionDialog.SetSelectedSession(msg.ID)
  235. case dialog.SessionSelectedMsg:
  236. a.showSessionDialog = false
  237. if a.currentPage == page.ChatPage {
  238. return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
  239. }
  240. return a, nil
  241. case dialog.CommandSelectedMsg:
  242. a.showCommandDialog = false
  243. // Execute the command handler if available
  244. if msg.Command.Handler != nil {
  245. return a, msg.Command.Handler(msg.Command)
  246. }
  247. return a, util.ReportInfo("Command selected: " + msg.Command.Title)
  248. case tea.KeyMsg:
  249. switch {
  250. case key.Matches(msg, keys.Quit):
  251. a.showQuit = !a.showQuit
  252. if a.showHelp {
  253. a.showHelp = false
  254. }
  255. if a.showSessionDialog {
  256. a.showSessionDialog = false
  257. }
  258. if a.showCommandDialog {
  259. a.showCommandDialog = false
  260. }
  261. return a, nil
  262. case key.Matches(msg, keys.SwitchSession):
  263. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
  264. // Load sessions and show the dialog
  265. sessions, err := a.app.Sessions.List(context.Background())
  266. if err != nil {
  267. return a, util.ReportError(err)
  268. }
  269. if len(sessions) == 0 {
  270. return a, util.ReportWarn("No sessions available")
  271. }
  272. a.sessionDialog.SetSessions(sessions)
  273. a.showSessionDialog = true
  274. return a, nil
  275. }
  276. return a, nil
  277. case key.Matches(msg, keys.Commands):
  278. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
  279. // Show commands dialog
  280. if len(a.commands) == 0 {
  281. return a, util.ReportWarn("No commands available")
  282. }
  283. a.commandDialog.SetCommands(a.commands)
  284. a.showCommandDialog = true
  285. return a, nil
  286. }
  287. return a, nil
  288. case key.Matches(msg, logsKeyReturnKey):
  289. if a.currentPage == page.LogsPage {
  290. return a, a.moveToPage(page.ChatPage)
  291. }
  292. case key.Matches(msg, returnKey):
  293. if a.showQuit {
  294. a.showQuit = !a.showQuit
  295. return a, nil
  296. }
  297. if a.showHelp {
  298. a.showHelp = !a.showHelp
  299. return a, nil
  300. }
  301. if a.showInitDialog {
  302. a.showInitDialog = false
  303. // Mark the project as initialized without running the command
  304. if err := config.MarkProjectInitialized(); err != nil {
  305. return a, util.ReportError(err)
  306. }
  307. return a, nil
  308. }
  309. case key.Matches(msg, keys.Logs):
  310. return a, a.moveToPage(page.LogsPage)
  311. case key.Matches(msg, keys.Help):
  312. if a.showQuit {
  313. return a, nil
  314. }
  315. a.showHelp = !a.showHelp
  316. return a, nil
  317. case key.Matches(msg, helpEsc):
  318. if a.app.CoderAgent.IsBusy() {
  319. if a.showQuit {
  320. return a, nil
  321. }
  322. a.showHelp = !a.showHelp
  323. return a, nil
  324. }
  325. }
  326. }
  327. if a.showQuit {
  328. q, quitCmd := a.quit.Update(msg)
  329. a.quit = q.(dialog.QuitDialog)
  330. cmds = append(cmds, quitCmd)
  331. // Only block key messages send all other messages down
  332. if _, ok := msg.(tea.KeyMsg); ok {
  333. return a, tea.Batch(cmds...)
  334. }
  335. }
  336. if a.showPermissions {
  337. d, permissionsCmd := a.permissions.Update(msg)
  338. a.permissions = d.(dialog.PermissionDialogCmp)
  339. cmds = append(cmds, permissionsCmd)
  340. // Only block key messages send all other messages down
  341. if _, ok := msg.(tea.KeyMsg); ok {
  342. return a, tea.Batch(cmds...)
  343. }
  344. }
  345. if a.showSessionDialog {
  346. d, sessionCmd := a.sessionDialog.Update(msg)
  347. a.sessionDialog = d.(dialog.SessionDialog)
  348. cmds = append(cmds, sessionCmd)
  349. // Only block key messages send all other messages down
  350. if _, ok := msg.(tea.KeyMsg); ok {
  351. return a, tea.Batch(cmds...)
  352. }
  353. }
  354. if a.showCommandDialog {
  355. d, commandCmd := a.commandDialog.Update(msg)
  356. a.commandDialog = d.(dialog.CommandDialog)
  357. cmds = append(cmds, commandCmd)
  358. // Only block key messages send all other messages down
  359. if _, ok := msg.(tea.KeyMsg); ok {
  360. return a, tea.Batch(cmds...)
  361. }
  362. }
  363. if a.showInitDialog {
  364. d, initCmd := a.initDialog.Update(msg)
  365. a.initDialog = d.(dialog.InitDialogCmp)
  366. cmds = append(cmds, initCmd)
  367. // Only block key messages send all other messages down
  368. if _, ok := msg.(tea.KeyMsg); ok {
  369. return a, tea.Batch(cmds...)
  370. }
  371. }
  372. s, _ := a.status.Update(msg)
  373. a.status = s.(core.StatusCmp)
  374. a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
  375. cmds = append(cmds, cmd)
  376. return a, tea.Batch(cmds...)
  377. }
  378. // RegisterCommand adds a command to the command dialog
  379. func (a *appModel) RegisterCommand(cmd dialog.Command) {
  380. a.commands = append(a.commands, cmd)
  381. }
  382. func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
  383. if a.app.CoderAgent.IsBusy() {
  384. // For now we don't move to any page if the agent is busy
  385. return util.ReportWarn("Agent is busy, please wait...")
  386. }
  387. var cmds []tea.Cmd
  388. if _, ok := a.loadedPages[pageID]; !ok {
  389. cmd := a.pages[pageID].Init()
  390. cmds = append(cmds, cmd)
  391. a.loadedPages[pageID] = true
  392. }
  393. a.previousPage = a.currentPage
  394. a.currentPage = pageID
  395. if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
  396. cmd := sizable.SetSize(a.width, a.height)
  397. cmds = append(cmds, cmd)
  398. }
  399. return tea.Batch(cmds...)
  400. }
  401. func (a appModel) View() string {
  402. components := []string{
  403. a.pages[a.currentPage].View(),
  404. }
  405. components = append(components, a.status.View())
  406. appView := lipgloss.JoinVertical(lipgloss.Top, components...)
  407. if a.showPermissions {
  408. overlay := a.permissions.View()
  409. row := lipgloss.Height(appView) / 2
  410. row -= lipgloss.Height(overlay) / 2
  411. col := lipgloss.Width(appView) / 2
  412. col -= lipgloss.Width(overlay) / 2
  413. appView = layout.PlaceOverlay(
  414. col,
  415. row,
  416. overlay,
  417. appView,
  418. true,
  419. )
  420. }
  421. if !a.app.CoderAgent.IsBusy() {
  422. a.status.SetHelpMsg("ctrl+? help")
  423. } else {
  424. a.status.SetHelpMsg("? help")
  425. }
  426. if a.showHelp {
  427. bindings := layout.KeyMapToSlice(keys)
  428. if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
  429. bindings = append(bindings, p.BindingKeys()...)
  430. }
  431. if a.showPermissions {
  432. bindings = append(bindings, a.permissions.BindingKeys()...)
  433. }
  434. if a.currentPage == page.LogsPage {
  435. bindings = append(bindings, logsKeyReturnKey)
  436. }
  437. if !a.app.CoderAgent.IsBusy() {
  438. bindings = append(bindings, helpEsc)
  439. }
  440. a.help.SetBindings(bindings)
  441. overlay := a.help.View()
  442. row := lipgloss.Height(appView) / 2
  443. row -= lipgloss.Height(overlay) / 2
  444. col := lipgloss.Width(appView) / 2
  445. col -= lipgloss.Width(overlay) / 2
  446. appView = layout.PlaceOverlay(
  447. col,
  448. row,
  449. overlay,
  450. appView,
  451. true,
  452. )
  453. }
  454. if a.showQuit {
  455. overlay := a.quit.View()
  456. row := lipgloss.Height(appView) / 2
  457. row -= lipgloss.Height(overlay) / 2
  458. col := lipgloss.Width(appView) / 2
  459. col -= lipgloss.Width(overlay) / 2
  460. appView = layout.PlaceOverlay(
  461. col,
  462. row,
  463. overlay,
  464. appView,
  465. true,
  466. )
  467. }
  468. if a.showSessionDialog {
  469. overlay := a.sessionDialog.View()
  470. row := lipgloss.Height(appView) / 2
  471. row -= lipgloss.Height(overlay) / 2
  472. col := lipgloss.Width(appView) / 2
  473. col -= lipgloss.Width(overlay) / 2
  474. appView = layout.PlaceOverlay(
  475. col,
  476. row,
  477. overlay,
  478. appView,
  479. true,
  480. )
  481. }
  482. if a.showCommandDialog {
  483. overlay := a.commandDialog.View()
  484. row := lipgloss.Height(appView) / 2
  485. row -= lipgloss.Height(overlay) / 2
  486. col := lipgloss.Width(appView) / 2
  487. col -= lipgloss.Width(overlay) / 2
  488. appView = layout.PlaceOverlay(
  489. col,
  490. row,
  491. overlay,
  492. appView,
  493. true,
  494. )
  495. }
  496. if a.showInitDialog {
  497. overlay := a.initDialog.View()
  498. appView = layout.PlaceOverlay(
  499. a.width/2-lipgloss.Width(overlay)/2,
  500. a.height/2-lipgloss.Height(overlay)/2,
  501. overlay,
  502. appView,
  503. true,
  504. )
  505. }
  506. return appView
  507. }
  508. func New(app *app.App) tea.Model {
  509. startPage := page.ChatPage
  510. model := &appModel{
  511. currentPage: startPage,
  512. loadedPages: make(map[page.PageID]bool),
  513. status: core.NewStatusCmp(app.LSPClients),
  514. help: dialog.NewHelpCmp(),
  515. quit: dialog.NewQuitCmp(),
  516. sessionDialog: dialog.NewSessionDialogCmp(),
  517. commandDialog: dialog.NewCommandDialogCmp(),
  518. permissions: dialog.NewPermissionDialogCmp(),
  519. initDialog: dialog.NewInitDialogCmp(),
  520. app: app,
  521. commands: []dialog.Command{},
  522. pages: map[page.PageID]tea.Model{
  523. page.ChatPage: page.NewChatPage(app),
  524. page.LogsPage: page.NewLogsPage(),
  525. },
  526. }
  527. model.RegisterCommand(dialog.Command{
  528. ID: "init",
  529. Title: "Initialize Project",
  530. Description: "Create/Update the OpenCode.md memory file",
  531. Handler: func(cmd dialog.Command) tea.Cmd {
  532. prompt := `Please analyze this codebase and create a OpenCode.md file containing:
  533. 1. Build/lint/test commands - especially for running a single test
  534. 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
  535. The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
  536. If there's already a opencode.md, improve it.
  537. If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
  538. return tea.Batch(
  539. util.CmdHandler(chat.SendMsg{
  540. Text: prompt,
  541. }),
  542. )
  543. },
  544. })
  545. return model
  546. }