diff --git a/internal/config/config.go b/internal/config/config.go index ed13473..02444bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,8 @@ package config import ( + "context" + "github.com/DomBlack/bubble-shell/pkg/config/keymap" "github.com/DomBlack/bubble-shell/pkg/config/styles" ) @@ -24,12 +26,25 @@ type Config struct { // // If empty no filtering will be done PackagesToFilterFromStack []string + + // InlineShell will cause the shell to be rendered inline + // rather than taking over the whole terminal + InlineShell bool + + // RootContext is the context that will be used for the + // commands when they run + RootContext context.Context + + // PromptFunc is a function that returns the prompt to be used + PromptFunc func() string } // Default returns a default configuration for the shell func Default() *Config { return &Config{ HistoryFile: ".bubble-shell-history", + RootContext: context.Background(), + PromptFunc: func() string { return "> " }, KeyMap: keymap.Default, Styles: styles.Default, MaxStackFrames: 8, diff --git a/mode_command_entry.go b/mode_command_entry.go index b12bfd6..775dc47 100644 --- a/mode_command_entry.go +++ b/mode_command_entry.go @@ -18,6 +18,7 @@ func (c *CommandEntryMode) Enter(m Model) (Model, tea.Cmd) { } m.lookBackPartial = "" + m.input.Prompt = m.cfg.PromptFunc() m.input.CursorEnd() m.input.Focus() @@ -64,11 +65,12 @@ func (c *CommandEntryMode) Update(m Model, msg tea.Msg) (Model, tea.Cmd) { return m, tea.Quit } - historyItem := history.NewItem(line, history.RunningStatus) + historyItem := history.NewItem(m.input.Prompt, line, history.RunningStatus) m.input.SetValue("") m.input.CursorEnd() - return m, tea.Batch( + return m, tea.Sequence( + m.Enter(&CommandRunningMode{}), m.history.AppendItem(historyItem), m.ExecuteCommand(historyItem), ) @@ -83,11 +85,7 @@ func (c *CommandEntryMode) Update(m Model, msg tea.Msg) (Model, tea.Cmd) { return m, m.Enter(&AutoCompleteMode{}) case key.Matches(msg, m.cfg.KeyMap.Cancel): - if m.currentCmdCancel != nil { - m.currentCmdCancel() - m.currentCmdCancel = nil - return m, nil - } else if m.input.Value() != "" { + if m.input.Value() != "" { m.input.SetValue("") m.input.CursorEnd() return m, nil diff --git a/mode_command_running.go b/mode_command_running.go new file mode 100644 index 0000000..a7bdb49 --- /dev/null +++ b/mode_command_running.go @@ -0,0 +1,60 @@ +package shell + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type CommandRunningMode struct{ KeepInputContent bool } + +var _ Mode = (*CommandRunningMode)(nil) + +func (c *CommandRunningMode) Enter(m Model) (Model, tea.Cmd) { + return m, nil +} + +func (c *CommandRunningMode) Leave(m Model) (Model, tea.Cmd) { + return m, nil +} + +func (c *CommandRunningMode) Update(m Model, msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.cfg.KeyMap.Cancel): + if m.currentCmdCancel != nil { + m.currentCmdCancel() + m.currentCmdCancel = nil + return m, nil + } else { + return m, tea.Quit + } + } + } + + return m, nil +} + +func (c *CommandRunningMode) AdditionalView(m Model) string { + return "" +} + +func (c *CommandRunningMode) ShortHelp(m Model, keyMap KeyMap) []key.Binding { + cancel := keyMap.Cancel + + if m.currentCmdCancel != nil { + cancel.SetHelp(cancel.Help().Key, "stop executing command") + } else { + cancel.SetHelp(cancel.Help().Key, "quit") + } + + return []key.Binding{ + cancel, + } +} + +func (c *CommandRunningMode) FullHelp(m Model, keyMap KeyMap) [][]key.Binding { + return [][]key.Binding{ + c.ShortHelp(m, keyMap), + } +} diff --git a/mode_history_lookback.go b/mode_history_lookback.go index 6658600..63897fd 100644 --- a/mode_history_lookback.go +++ b/mode_history_lookback.go @@ -34,10 +34,10 @@ func (h *HistoryLookbackMode) Update(m Model, msg tea.Msg) (Model, tea.Cmd) { switch { case key.Matches(msg, m.cfg.KeyMap.ExecuteCommand): line := m.input.Value() - historyItem := history.NewItem(line, history.RunningStatus) + historyItem := history.NewItem(m.input.Prompt, line, history.RunningStatus) return m, tea.Sequence( - m.Enter(&CommandEntryMode{}), + m.Enter(&CommandRunningMode{}), m.history.AppendItem(historyItem), m.ExecuteCommand(historyItem), ) diff --git a/mode_history_search.go b/mode_history_search.go index 8e192db..9d746ab 100644 --- a/mode_history_search.go +++ b/mode_history_search.go @@ -32,9 +32,9 @@ func (h *HistorySearchMode) Update(m Model, msg tea.Msg) (Model, tea.Cmd) { switch { case key.Matches(msg, m.cfg.KeyMap.ExecuteCommand): line := m.input.Value() - historyItem := history.NewItem(line, history.RunningStatus) + historyItem := history.NewItem(m.input.Prompt, line, history.RunningStatus) return m, tea.Sequence( - m.Enter(&CommandEntryMode{}), + m.Enter(&CommandRunningMode{}), m.history.AppendItem(historyItem), m.ExecuteCommand(historyItem), ) diff --git a/model.go b/model.go index f9932ef..95a9963 100644 --- a/model.go +++ b/model.go @@ -181,34 +181,39 @@ func (m Model) View() string { input := m.input.View() modeView := m.mode.AdditionalView(m) - // Fit the history to the screen based on the output of the autocomplete and if we're showing the search input - neededHistoryHeight := m.height - 1 // one for the prompt - if modeView != "" { - neededHistoryHeight -= lipgloss.Height(modeView) + if !m.cfg.InlineShell { + // Fit the history to the screen based on the output of the autocomplete and if we're showing the search input + neededHistoryHeight := m.height - 1 // one for the prompt + if modeView != "" { + neededHistoryHeight -= lipgloss.Height(modeView) + } + + for lipgloss.Height(historyView) > neededHistoryHeight { + firstNewLine := strings.IndexRune(historyView, '\n') + historyView = historyView[firstNewLine+1:] + } } - for lipgloss.Height(historyView) > neededHistoryHeight { - firstNewLine := strings.IndexRune(historyView, '\n') - historyView = historyView[firstNewLine+1:] + // If we're an inline shell and the previous command is still running, wait for it to finish + lastCmd := m.history.Lookback(1) + if m.cfg.InlineShell && !lastCmd.LoadedHistory && !lastCmd.Started.IsZero() && lastCmd.Finished.IsZero() { + input = "" } - // Now decide how to render the view + // Now render all the parts vertically + parts := make([]string, 0, 3) + parts = append(parts, historyView) + if input != "" { + parts = append(parts, input) + } if modeView != "" { - return lipgloss.JoinVertical(lipgloss.Top, - historyView, - input, - modeView, - ) - } else { - return lipgloss.JoinVertical(lipgloss.Top, - historyView, - input, - ) + parts = append(parts, modeView) } + return lipgloss.JoinVertical(lipgloss.Top, parts...) } func (m Model) ExecuteCommand(cmd history.Item) tea.Cmd { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(m.cfg.RootContext) return tea.Batch( func() tea.Msg { @@ -229,8 +234,10 @@ func (m Model) ExecuteCommand(cmd history.Item) tea.Cmd { // Capture the stdout cmd.Output = strings.TrimSpace(string(stdoutBuffer.Bytes())) - // Create an UpdateItem [tea.Cmd] and then execute it - return m.history.UpdateItem(cmd)() + return tea.Sequence( + m.history.UpdateItem(cmd), // Create an UpdateItem [tea.Cmd] + m.Enter(&CommandEntryMode{}), // Then switch back to command entry mode + )() }, ) } diff --git a/options.go b/options.go index 167ac6d..6dbbd93 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,8 @@ package shell import ( + "context" + "github.com/DomBlack/bubble-shell/internal/config" "github.com/DomBlack/bubble-shell/pkg/config/keymap" "github.com/DomBlack/bubble-shell/pkg/config/styles" @@ -80,3 +82,36 @@ func WithStackTraceFilters(packages ...string) Option { o.PackagesToFilterFromStack = packages } } + +// WithInlineShell sets the shell to be inline rather than trying to render full screen +// +// This means that recovered history will not be shown, however your terminals own render +// will be incharge of scrolling. +func WithInlineShell() Option { + return func(o *config.Config) { + o.InlineShell = true + } +} + +// WithBaseContext sets the context that commands will be run with +// when they are executed by users. +// +// By default [context.Background] will be used +func WithBaseContext(ctx context.Context) Option { + return func(o *config.Config) { + o.RootContext = ctx + } +} + +// WithPromptFunc sets the function for rendering the prompt +// +// By default a function will be provided that returns "> " +func WithPromptFunc(promptFunc func() string) Option { + if promptFunc == nil { + panic("promptFunc cannot be nil") + } + + return func(o *config.Config) { + o.PromptFunc = promptFunc + } +} diff --git a/pkg/tui/autocomplete/model.go b/pkg/tui/autocomplete/model.go index e094b3f..4bf729c 100644 --- a/pkg/tui/autocomplete/model.go +++ b/pkg/tui/autocomplete/model.go @@ -156,7 +156,7 @@ func (m Model) View() string { columns := make([][]string, numColumns) for i, option := range m.options { - colNum := i / perColumn + colNum := i % perColumn if i == m.selectedOption { columns[colNum] = append(columns[colNum], m.selectedOptionStyle.Render(option.Name)) diff --git a/pkg/tui/history/history_io.go b/pkg/tui/history/history_io.go index 5023d26..ca9cc5a 100644 --- a/pkg/tui/history/history_io.go +++ b/pkg/tui/history/history_io.go @@ -63,7 +63,11 @@ func (m Model) ReadHistory() tea.Cmd { return nil, err } if part { - return nil, errors.New("line too long") + if m.cfg.InlineShell { + continue + } else { + return nil, errors.New("line too long") + } } if !utf8.Valid(line) { return nil, errors.New("line isn't valid utf8") @@ -73,6 +77,7 @@ func (m Model) ReadHistory() tea.Cmd { if err := json.Unmarshal(line, &item); err != nil { return nil, errors.Wrap(err, "unable to unmarshal history item") } + item.LoadedHistory = true history = append(history, item) if len(history) > Limit { @@ -82,7 +87,8 @@ func (m Model) ReadHistory() tea.Cmd { // Mark the history as restored if there is any history if len(history) > 0 { - item := NewItem("restored history from previous session", SuccessStatus) + item := NewItem("", "restored history from previous session", SuccessStatus) + item.LoadedHistory = true item.ItemType = HistoryRestored history = append(history, item) } @@ -93,7 +99,7 @@ func (m Model) ReadHistory() tea.Cmd { return func() tea.Msg { history, err := readHistory() if err != nil { - item := NewItem("error loading history file", ErrorStatus) + item := NewItem("", "error loading history file", ErrorStatus) item.ItemType = InternalError item.Error = err @@ -151,7 +157,7 @@ func (m Model) SaveHistory(items []Item) tea.Cmd { return func() tea.Msg { err := saveHistory() if err != nil { - item := NewItem("error saving history file", ErrorStatus) + item := NewItem("", "error saving history file", ErrorStatus) item.ItemType = InternalError item.Error = err diff --git a/pkg/tui/history/item.go b/pkg/tui/history/item.go index 09a171c..34653b0 100644 --- a/pkg/tui/history/item.go +++ b/pkg/tui/history/item.go @@ -36,6 +36,7 @@ const ( type Item struct { // This group of fields are serialized and stored ID xid.ID `json:"id"` // The unique ID of the command + Prompt string `json:"prompt"` // The prompt that was displayed when the command was executed Line string `json:"line"` // The command executed Started time.Time `json:"started"` // The time the command was executed Finished time.Time `json:"finished"` // The time the command finished executing @@ -44,14 +45,16 @@ type Item struct { // This group of fields are not serialized and are only used // for rendering the UI during the current shell session - ItemType ItemType `json:"-"` // If true then this item is an internal error item and not a user command - Error error `json:"-"` // The error returned from the command + ItemType ItemType `json:"-"` // If true then this item is an internal error item and not a user command + Error error `json:"-"` // The error returned from the command + LoadedHistory bool `json:"-"` // If true then this item is a history restored item and not a user command } // NewItem creates a new history item with the given line and status -func NewItem(line string, status Status) Item { +func NewItem(prompt, line string, status Status) Item { return Item{ ID: xid.New(), + Prompt: prompt, Line: line, Started: time.Now(), Status: status, @@ -94,7 +97,12 @@ func (i Item) View(cfg *config.Config, width int) string { lines[0] = cfg.Styles.HistoricLine.Render(i.Line) switch i.ItemType { case Command: - lines[0] = cfg.Styles.HistoricPrompt.Render("> ") + lines[0] + prompt := i.Prompt + if prompt == "" { + prompt = "> " + } + + lines[0] = cfg.Styles.HistoricPrompt.Render(prompt) + lines[0] case InternalError: lines[0] = cfg.Styles.InternalError.Render("!! ") + lines[0] diff --git a/pkg/tui/history/model.go b/pkg/tui/history/model.go index 765a1d2..59bd430 100644 --- a/pkg/tui/history/model.go +++ b/pkg/tui/history/model.go @@ -77,6 +77,17 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // item and then saves the history case updateItemMsg: if m.id.Matches(msg) { + var cmds []tea.Cmd + + // If we're an inline shell and the item is no longer running + // then we need to print it outside the bubbletea managed area + // so the normal shell scrollbar will work + if m.cfg.InlineShell && msg.Item.Status > RunningStatus { + // Mark it as loaded history so we wont render it within the bubble tea program now + msg.Item.LoadedHistory = true + cmds = append(cmds, tea.Println(msg.Item.View(m.cfg, m.width))) + } + // Start searching from the end of the slice as // 99 times out of 100 we'll be updating the most // recent item @@ -93,10 +104,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // If we didn't find it then we need to add it if !found { - return m, m.AppendItem(msg.Item) + cmds = append(cmds, m.AppendItem(msg.Item)) + } else { + cmds = append(cmds, m.SaveHistory(m.Items)) } - return m, m.SaveHistory(m.Items) + return m, tea.Batch(cmds...) } } @@ -107,6 +120,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) View() string { lineRender := lipgloss.NewStyle().Width(m.width) + // If we're inline, just render everything all at once - except for loaded history + if m.cfg.InlineShell { + lines := make([]string, 0, len(m.Items)) + for _, item := range m.Items { + if !item.LoadedHistory { + lines = append(lines, lineRender.Render(item.View(m.cfg, m.width))) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) + } + // Render the lines in reverse order // But we only want a maximum of m.height lines lineCount := 0