initial commit
commit
fe45414521
@ -0,0 +1,9 @@
|
||||
# Compiled files
|
||||
bin/
|
||||
out/
|
||||
|
||||
# Local files
|
||||
*.local*
|
||||
*.env
|
||||
*.log
|
||||
*.tmp
|
||||
@ -0,0 +1,23 @@
|
||||
# NextShell
|
||||
|
||||
Next generation terminal TUI shell built with Go and Bubble Tea.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Basic layout with tree view and scrollable commands output cells
|
||||
|
||||
- [ ] File system tree view with basic navigation
|
||||
|
||||
- [ ] Basic shell (`sh`) command execution
|
||||
|
||||
- [ ] Parallel command execution with live output streaming
|
||||
|
||||
- [ ] Simple shell-like language inspired by shell, lisp and tcl
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
$ git clone git@git.phc.dm.unipi.it:aziis98/tui-shell.git
|
||||
$ cd tui-shell
|
||||
$ go run -v .
|
||||
```
|
||||
@ -0,0 +1,31 @@
|
||||
module nextshell
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
@ -0,0 +1,53 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
@ -0,0 +1,349 @@
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func flattenTree(n *TreeNode, depth int) []string {
|
||||
var out []string
|
||||
prefix := strings.Repeat(" ", depth)
|
||||
icon := "▸"
|
||||
if n.Expanded && len(n.Children) > 0 {
|
||||
icon = "▾"
|
||||
} else if len(n.Children) == 0 {
|
||||
icon = " "
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s%s %s", prefix, icon, n.Label))
|
||||
if n.Expanded {
|
||||
for _, c := range n.Children {
|
||||
out = append(out, flattenTree(c, depth+1)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
)
|
||||
|
||||
func initialModel() model {
|
||||
// detailed tree to showcase design
|
||||
root := &TreeNode{
|
||||
Label: "nextshell-mock-folder",
|
||||
Expanded: true,
|
||||
Children: []*TreeNode{
|
||||
{
|
||||
Label: "cmd",
|
||||
Expanded: true,
|
||||
Children: []*TreeNode{
|
||||
{Label: "build"},
|
||||
{Label: "deploy"},
|
||||
{Label: "test"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "pkg",
|
||||
Expanded: true,
|
||||
Children: []*TreeNode{
|
||||
{
|
||||
Label: "http",
|
||||
Expanded: true,
|
||||
Children: []*TreeNode{
|
||||
{Label: "client.go"},
|
||||
{Label: "server.go"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "ui",
|
||||
Expanded: true,
|
||||
Children: []*TreeNode{
|
||||
{Label: "main.go"},
|
||||
{Label: "widgets.go"},
|
||||
{Label: "styles.go"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "configs",
|
||||
Children: []*TreeNode{
|
||||
{Label: "dev.yaml"},
|
||||
{Label: "prod.yaml"},
|
||||
},
|
||||
},
|
||||
{Label: "README.md"},
|
||||
{Label: "Makefile"},
|
||||
},
|
||||
}
|
||||
|
||||
// sample commands to showcase cards, various sizes
|
||||
commands := []Command{
|
||||
{
|
||||
Input: "help",
|
||||
Result: dedent(`
|
||||
Available commands:
|
||||
- help Show this message
|
||||
- ls List files
|
||||
- cat FILE Show file contents
|
||||
- run TASK Run a task
|
||||
|
||||
Tip: Use 'ls -la' for details.`,
|
||||
),
|
||||
},
|
||||
{
|
||||
Input: "ls -la /home/alice/projects",
|
||||
Result: dedent(`
|
||||
total 96
|
||||
drwxr-xr-x 12 alice alice 4096 Aug 22 21:00 .
|
||||
drwxr-xr-x 3 alice alice 4096 Aug 10 09:10 ..
|
||||
-rw-r--r-- 1 alice alice 220 Aug 1 12:00 README.md
|
||||
drwxr-xr-x 4 alice alice 4096 Aug 22 20:58 src
|
||||
drwxr-xr-x 3 alice alice 4096 Aug 22 20:55 pkg
|
||||
drwxr-xr-x 5 alice alice 4096 Aug 22 20:59 ui
|
||||
`),
|
||||
},
|
||||
{
|
||||
Input: "cat src/ui/main.go",
|
||||
Result: dedent(`
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("ui starting...")
|
||||
// ...lots of code...
|
||||
}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
Input: "run test-suite --verbose",
|
||||
Result: longMockOutput(60), // long output to show truncation and scrolling in card result
|
||||
},
|
||||
{
|
||||
Input: "grep -R \"TODO\" .",
|
||||
Result: dedent(`
|
||||
src/ui/widgets.go:23:// TODO: refactor toolbar
|
||||
pkg/http/server.go:78:// TODO: handle timeouts
|
||||
README.md:4:<!-- TODO: add usage examples -->`,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
v := viewport.New(90, 30)
|
||||
|
||||
t := textarea.New()
|
||||
t.ShowLineNumbers = false
|
||||
t.Prompt = "$ "
|
||||
|
||||
m := model{
|
||||
treeRoot: root,
|
||||
viewport: v,
|
||||
commands: commands,
|
||||
textarea: t,
|
||||
}
|
||||
|
||||
// set viewport content with current commands
|
||||
m.resize(120, 40) // initial reasonable size for demo; WindowSizeMsg will update on real terminal
|
||||
m.viewport.SetContent(m.renderCommandsContent())
|
||||
m.viewport.GotoBottom()
|
||||
|
||||
return m
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dedent(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
|
||||
// Remove leading/trailing empty lines
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
// Use the next line to determine indentation pattern
|
||||
indent := regexp.MustCompile(`^\s*`).FindString(lines[0])
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimPrefix(line, indent)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func longMockOutput(lines int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Running test-suite...\n\n")
|
||||
for i := 1; i <= lines; i++ {
|
||||
fmt.Fprintf(&b, "ok %02d package/name - test case %d passed\n", i%10, i)
|
||||
}
|
||||
b.WriteString("\nSUMMARY: 58 passed, 2 skipped, 0 failed\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func mockResultFor(input string) string {
|
||||
// simple mock: echo input and add a few lines for realism
|
||||
switch input {
|
||||
case "whoami":
|
||||
return "alice"
|
||||
case "date":
|
||||
return "Fri Aug 22 21:23:00 CEST 2025"
|
||||
case "uptime":
|
||||
return " 21:23:00 up 3 days, 4:12, 2 users, load average: 0.42, 0.50, 0.47"
|
||||
default:
|
||||
return fmt.Sprintf("Mock result for: %s\n\n%s", input, longMockOutput(20))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue