// main.go package main import ( "fmt" "log" "os" "path" "strings" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type TreeNode struct { Label string Children []*TreeNode Expanded bool } type Command struct { Input string Result string } type model struct { width int height int leftWidth int treeRoot *TreeNode treeCursor int textarea textarea.Model commands []Command viewport viewport.Model focusPane string // "prompt", "fileview", "commands" } var ( borderStyle = lipgloss.NormalBorder() paneBoxStyle = lipgloss.NewStyle(). Padding(0, 1). Border(borderStyle). BorderForeground(lipgloss.Color("0")) paneBoxFocusedStyle = lipgloss.NewStyle(). Padding(0, 1). Border(borderStyle). BorderForeground(lipgloss.Color("6")) commandBoxInputStyle = lipgloss.NewStyle(). Bold(true) commandBoxResultStyle = lipgloss.NewStyle(). Background(lipgloss.Color("15")). MaxHeight(8) ) func main() { p := tea.NewProgram(initialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err := p.Run() if err != nil { fmt.Fprintf(os.Stderr, "Error running program: %v\n", err) os.Exit(1) } } // returns both the string representation and corresponding nodes func flattenTreeWithNodes(n *TreeNode, depth int) ([]string, []*TreeNode) { var lines []string var nodes []*TreeNode prefix := strings.Repeat(" ", depth) icon := "▸" if n.Expanded && len(n.Children) > 0 { icon = "▾" } else if len(n.Children) == 0 { icon = " " } lines = append(lines, fmt.Sprintf("%s%s %s", prefix, icon, n.Label)) nodes = append(nodes, n) if n.Expanded { for _, c := range n.Children { childLines, childNodes := flattenTreeWithNodes(c, depth+1) lines = append(lines, childLines...) nodes = append(nodes, childNodes...) } } return lines, nodes } func flattenTree(n *TreeNode, depth int) []string { lines, _ := flattenTreeWithNodes(n, depth) return lines } // toggleNodeExpansion toggles the expanded state of a node if it has children func (m *model) toggleNodeExpansion(node *TreeNode) { if len(node.Children) > 0 { node.Expanded = !node.Expanded } } func (m *model) renderCommandsContent() string { if len(m.commands) == 0 { return "(no commands yet)" } commandBoxes := []string{} for _, c := range m.commands { // input header + result with limited height per card (visual truncation) commandBoxes = append(commandBoxes, paneBoxStyle.Render( lipgloss.JoinVertical( lipgloss.Left, commandBoxInputStyle.Render( fmt.Sprintf("$ %s", strings.ReplaceAll(strings.TrimSpace(c.Input), "\n", " ↵ "), ), ), commandBoxResultStyle. Render(limitedResult(c.Result, 8)), ), ), ) } return lipgloss.JoinVertical(lipgloss.Left, commandBoxes...) } func limitedResult(result string, maxLines int) string { lines := strings.Split(result, "\n") if len(lines) <= maxLines { return strings.Join(lines, "\n") } trunc := strings.Join(lines[:maxLines], "\n") trunc += "\n" + lipgloss.NewStyle().Italic(true).Render("…") return trunc } func (m model) rightWidth() int { return m.width - m.leftWidth - 1 } func (m model) rightContentWidth() int { return m.rightWidth() - 4 } func (m model) promptContentHeight() int { return 1 + len(m.textarea.Value())/(m.rightContentWidth()-5) } func (m *model) resize(w, h int) { m.width = w m.height = h m.leftWidth = w / 5 m.viewport.Width = m.rightContentWidth() m.viewport.Height = h - m.promptContentHeight() - 5 m.textarea.SetWidth(m.rightContentWidth() - 2) m.textarea.SetHeight(m.promptContentHeight()) m.viewport.SetContent(m.renderCommandsContent()) } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{} if m.focusPane == "" { m.focusPane = "prompt" cmds = append(cmds, m.textarea.Focus()) } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "tab": // cycle focus: prompt -> fileview -> commands -> prompt switch m.focusPane { case "prompt": m.focusPane = "fileview" m.textarea.Blur() case "fileview": m.focusPane = "commands" m.textarea.Blur() default: m.focusPane = "prompt" return m, m.textarea.Focus() } return m, nil } // route keys based on which pane has focus if m.focusPane == "prompt" { // prompt (textarea) behaviour // submit on Enter (textarea normally inserts newlines; here Enter submits) if msg.Type == tea.KeyEnter { input := strings.TrimSpace(m.textarea.Value()) if input != "" { mock := mockResultFor(input) m.commands = append(m.commands, Command{ Input: input, Result: mock, }) m.viewport.SetContent(m.renderCommandsContent()) m.viewport.GotoBottom() m.textarea.SetValue("") } if m.width > 0 { m.resize(m.width, m.height) } return m, nil } m.textarea.SetHeight(m.promptContentHeight()) // forward other key messages to textarea var taCmd tea.Cmd m.textarea, taCmd = m.textarea.Update(msg) if m.width > 0 { m.resize(m.width, m.height) } cmds = append(cmds, taCmd) return m, tea.Batch(cmds...) } if m.focusPane == "fileview" { // fileview tree behaviour (navigation, expand/collapse) _, nodes := flattenTreeWithNodes(m.treeRoot, 0) switch msg.String() { case "up", "k": if m.treeCursor > 0 { m.treeCursor-- } case "down", "j": if m.treeCursor < len(nodes)-1 { m.treeCursor++ } case "left", "h": if m.treeCursor < len(nodes) { node := nodes[m.treeCursor] if node.Expanded && len(node.Children) > 0 { node.Expanded = false } } case "right", "l": if m.treeCursor < len(nodes) { node := nodes[m.treeCursor] if !node.Expanded && len(node.Children) > 0 { node.Expanded = true } } case "enter", " ": // toggle if m.treeCursor < len(nodes) { m.toggleNodeExpansion(nodes[m.treeCursor]) } } return m, nil } if m.focusPane == "commands" { // commands viewport behaviour (scrolling) switch msg.String() { case "up", "k": m.viewport.ScrollUp(1) case "down", "j": m.viewport.ScrollDown(1) case "pgup": m.viewport.ScrollUp(7) case "pgdown": m.viewport.ScrollDown(7) case "home": m.viewport.GotoTop() case "end": m.viewport.GotoBottom() } return m, nil } case tea.WindowSizeMsg: m.resize(msg.Width, msg.Height) return m, nil } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() string { var fileviewRows []string // TODO: not good practice to throw in View, should add a field to model instead homeDir, err := os.UserHomeDir() if err != nil { log.Fatal(err) } wd, err := os.Getwd() if err != nil { log.Fatal(err) } wd = path.Dir(wd) if strings.HasPrefix(wd, homeDir) { wd = strings.Replace(wd, homeDir, "~", 1) } parts := strings.Split(wd, string(os.PathSeparator)) // show full path as series of folders at top of fileview for _, part := range parts { fileviewRows = append(fileviewRows, lipgloss.NewStyle(). Bold(true). Render("▼ "+part+"/"), ) } treeLines := flattenTree(m.treeRoot, 0) // highlight the current selection when fileview is focused if m.focusPane == "fileview" && m.treeCursor < len(treeLines) { selectedStyle := lipgloss.NewStyle(). Background(lipgloss.Color("6")). Foreground(lipgloss.Color("0")) treeLines[m.treeCursor] = selectedStyle.Render(treeLines[m.treeCursor]) } // show the tree view content fileviewRows = append(fileviewRows, treeLines...) // build the main layout paneStyleDynamic := func(paneName string) lipgloss.Style { if m.focusPane == paneName { return paneBoxFocusedStyle } return paneBoxStyle } fileviewBox := paneStyleDynamic("fileview"). Width(m.leftWidth). Height(m.height - 2). Render( strings.Join(fileviewRows, "\n"), ) commandsBox := paneStyleDynamic("commands"). Width(m.rightContentWidth()). Render(m.viewport.View()) promptBox := paneStyleDynamic("prompt"). Width(m.rightContentWidth()). Height(m.promptContentHeight()). Render(m.textarea.View()) statusBarHelp := lipgloss.NewStyle(). Faint(true). Render( strings.Join([]string{ "tab: toggle focus", "enter: submit", "up/down/left/right: tree nav", "pgup/pgdn: scroll", "q/ctrl+c: quit", }, " • "), ) return lipgloss.JoinHorizontal( lipgloss.Top, fileviewBox, lipgloss.NewStyle(). PaddingLeft(1). Render( lipgloss.JoinVertical( lipgloss.Left, commandsBox, promptBox, statusBarHelp, ), ), ) }