commit fe4541452165f0c2a7f5cb5049875d5b5e6066d8 Author: Antonio De Lucreziis Date: Mon Aug 25 19:00:58 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8136a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Compiled files +bin/ +out/ + +# Local files +*.local* +*.env +*.log +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..6521bd8 --- /dev/null +++ b/README.md @@ -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 . +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8e15f55 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..da55769 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a955657 --- /dev/null +++ b/main.go @@ -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, + ), + ), + ) +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..b2f3490 --- /dev/null +++ b/model.go @@ -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:`, + ), + }, + } + + 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 +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..6c1cb06 --- /dev/null +++ b/utils.go @@ -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)) + } +}