initial commit

main
Antonio De Lucreziis 9 months ago
commit fe45414521

9
.gitignore vendored

@ -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…
Cancel
Save