Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cmd/gsh/defaults/starship.gsh
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ __starship_available = __starship_check.exitCode == 0
# Set up environment variables for Starship if available
if (__starship_available) {
env.STARSHIP_SHELL = "gsh"

# Initialize starship session (for transient prompt support)
__starship_session = exec("starship session 2>/dev/null || echo ''")
if (__starship_session.exitCode == 0) {
env.STARSHIP_SESSION_KEY = __starship_session.stdout
}

# Cache the continuation prompt at initialization (it's static from starship.toml)
__starship_cont = exec("starship prompt --continuation 2>/dev/null")
if (__starship_cont.exitCode == 0 && __starship_cont.stdout != "") {
__starship_cached_cont = __starship_cont.stdout
}
}

# Prompt handler - uses Starship if available, otherwise falls back to simple prompt
Expand All @@ -31,6 +37,12 @@ tool onReplPrompt(ctx, next) {
} else {
gsh.prompt = __starship_result.stdout
}

# Use cached continuation prompt
if (__starship_cached_cont != null) {
gsh.continuationPrompt = __starship_cached_cont
}

return next(ctx)
}
}
Expand Down
19 changes: 19 additions & 0 deletions docs/sdk/01-gsh-object.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ gsh.on("repl.prompt", dynamicPrompt)

For more prompt customization options including Starship integration, see the [Tutorial](../tutorial/02-configuration.md).

## `gsh.continuationPrompt`

**Type:** `string` (read/write)
**Availability:** REPL only

Sets the continuation prompt displayed on subsequent lines when entering multi-line input (e.g., unclosed quotes, heredocs, or trailing `|`). Defaults to `"> "`.

When Starship is available, gsh automatically uses `starship prompt --continuation` to set this (configurable via `starship.toml`).

### Example

```gsh
tool myPrompt() {
gsh.prompt = "my-shell> "
gsh.continuationPrompt = "... "
}
gsh.on("repl.prompt", myPrompt)
```

## `gsh.lastCommand`

**Type:** `object` (read-only)
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/05-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ gsh.use("repl.ready", welcome)

### `repl.prompt`

Fired after each command to generate the shell prompt. Set `gsh.prompt` to customize.
Fired after each command to generate the shell prompt. Set `gsh.prompt` to customize. You can also set `gsh.continuationPrompt` for multi-line input (see [gsh.continuationPrompt](01-gsh-object.md#gshcontinuationprompt)).

**Context:** `null`

Expand Down
21 changes: 21 additions & 0 deletions docs/tutorial/01-getting-started-with-gsh.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ It's on the roadmap to allow users to customize these key bindings.
- **Line Start**: `Home`, `Ctrl+A`
- **Line End**: `End`, `Ctrl+E`
- **Paste**: `Ctrl+V`
- **Insert Newline**: `Alt+Enter`

## Multi-line Input

gsh automatically detects incomplete input—unclosed quotes, heredocs, trailing pipes (`|`), `&&`, `||`, and control structures like `if`/`while` without their closing keywords. When you press **Enter** on incomplete input, gsh inserts a newline and shows a continuation prompt (`> `) so you can keep typing:

```bash
gsh> echo "hello
> world"
hello
world

gsh> if true; then
> echo hi
> fi
hi
```

You can also force a newline at any time with **Alt+Enter**, even when the input is already complete.

The continuation prompt can be customized via `gsh.continuationPrompt` — see the [SDK Reference](../sdk/01-gsh-object.md#gshcontinuationprompt).

## Basic Shell Experience

Expand Down
2 changes: 1 addition & 1 deletion internal/appupdate/appupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import (
"strings"

"github.com/Masterminds/semver/v3"
"github.com/creativeprojects/go-selfupdate"
"github.com/kunchenguid/gsh/internal/core"
"github.com/kunchenguid/gsh/internal/filesystem"
"github.com/creativeprojects/go-selfupdate"
"go.uber.org/zap"
)

Expand Down
2 changes: 1 addition & 1 deletion internal/history/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
"strings"
"time"

"github.com/kunchenguid/gsh/internal/core"
"github.com/glebarez/sqlite"
"github.com/kunchenguid/gsh/internal/core"
"gorm.io/gorm"
)

Expand Down
43 changes: 35 additions & 8 deletions internal/repl/input/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ import (
)

// handleSubmit handles the Enter key.
// If the input is incomplete (unclosed quotes, heredocs, etc.), it inserts a
// newline to allow multi-line editing. Otherwise, it submits the input.
func (m Model) handleSubmit() (tea.Model, tea.Cmd) {
text := m.buffer.Text()

if !IsInputComplete(text) {
// Input is incomplete — insert newline and continue editing
m.buffer.InsertRunes([]rune{'\n'})
m.historyIndex = 0
m.hasNavigatedHistory = false
return m.onTextChanged()
}

m.result = Result{
Type: ResultSubmit,
Value: m.buffer.Text(),
Value: text,
}
return m, tea.Quit
}
Expand All @@ -33,6 +45,14 @@ func (m Model) handleEOF() (tea.Model, tea.Cmd) {
return m, tea.Quit
}

// handleInsertNewline force-inserts a newline regardless of input completeness.
func (m Model) handleInsertNewline() (tea.Model, tea.Cmd) {
m.buffer.InsertRunes([]rune{'\n'})
m.historyIndex = 0
m.hasNavigatedHistory = false
return m.onTextChanged()
}

// handleCancel handles the Escape key.
func (m Model) handleCancel() (tea.Model, tea.Cmd) {
if m.completion.IsActive() {
Expand Down Expand Up @@ -311,15 +331,22 @@ func (m *Model) applyCompletion(suggestion string) {
m.completion.UpdateBoundaries(suggestion, newStart, newEnd)
}

// sanitizeRunes cleans up input runes by replacing tabs and newlines with spaces.
// sanitizeRunes cleans up input runes by replacing tabs with spaces and
// normalizing line endings. CRLF (\r\n) and lone \r are converted to \n.
func sanitizeRunes(runes []rune) []rune {
result := make([]rune, len(runes))
for i, r := range runes {
switch r {
case '\t', '\n', '\r':
result[i] = ' '
result := make([]rune, 0, len(runes))
for i := 0; i < len(runes); i++ {
switch runes[i] {
case '\t':
result = append(result, ' ')
case '\r':
// Normalize \r\n to \n, and lone \r to \n
result = append(result, '\n')
if i+1 < len(runes) && runes[i+1] == '\n' {
i++ // skip the \n in \r\n pair
}
default:
result[i] = r
result = append(result, runes[i])
}
}
return result
Expand Down
25 changes: 20 additions & 5 deletions internal/repl/input/highlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import (
"path/filepath"
"strings"

"github.com/kunchenguid/gsh/internal/repl/render"
"github.com/charmbracelet/lipgloss"
"github.com/kunchenguid/gsh/internal/repl/render"
"mvdan.cc/sh/v3/syntax"
)

Expand Down Expand Up @@ -359,19 +359,19 @@ func (h *Highlighter) highlightBasic(input string) string {

case r == '#':
// Comment - rest of line
result.WriteString(h.styles[TokenComment].Render(string(runes[i:])))
result.WriteString(renderStyled(h.styles[TokenComment], string(runes[i:])))
i = len(runes)

case r == '"':
// Double quoted string
end := h.findStringEnd(runes, i, '"')
result.WriteString(h.styles[TokenString].Render(string(runes[i:end])))
result.WriteString(renderStyled(h.styles[TokenString], string(runes[i:end])))
i = end

case r == '\'':
// Single quoted string
end := h.findStringEnd(runes, i, '\'')
result.WriteString(h.styles[TokenString].Render(string(runes[i:end])))
result.WriteString(renderStyled(h.styles[TokenString], string(runes[i:end])))
i = end

case r == '$':
Expand Down Expand Up @@ -545,7 +545,7 @@ func (h *Highlighter) renderSpans(spans []tokenSpan, input string) string {

// Add styled span
if span.end <= len(input) {
result.WriteString(span.style.Render(input[span.start:span.end]))
result.WriteString(renderStyled(span.style, input[span.start:span.end]))
lastEnd = span.end
}
}
Expand All @@ -571,6 +571,21 @@ func sortSpans(spans []tokenSpan) {
}
}

// renderStyled renders text with a lipgloss style, handling newlines correctly.
// lipgloss.Style.Render() pads shorter lines in multi-line text, which breaks
// character-by-character correspondence needed for cursor and wrapping.
// This function renders each line independently to avoid the padding issue.
func renderStyled(style lipgloss.Style, text string) string {
if !strings.Contains(text, "\n") {
return style.Render(text)
}
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = style.Render(line)
}
return strings.Join(lines, "\n")
}

// Helper functions for basic highlighting

func (h *Highlighter) findStringEnd(runes []rune, start int, quote rune) int {
Expand Down
22 changes: 21 additions & 1 deletion internal/repl/input/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ type Model struct {
focused bool

// Prompt
prompt string
prompt string
continuationPrompt string

// History navigation
historyValues []string
Expand Down Expand Up @@ -121,6 +122,10 @@ type Config struct {
// RenderConfig provides styling. If nil, DefaultRenderConfig is used.
RenderConfig *RenderConfig

// ContinuationPrompt is the prompt shown on continuation lines for multi-line input.
// If empty, defaults to "> ".
ContinuationPrompt string

// MinHeight is the minimum number of lines to render.
MinHeight int

Expand Down Expand Up @@ -154,14 +159,21 @@ func New(cfg Config) Model {
width = 80
}

continuationPrompt := cfg.ContinuationPrompt
if continuationPrompt == "" {
continuationPrompt = "> "
}

renderer := NewRenderer(*renderConfig, NewHighlighter(cfg.AliasExistsFunc, cfg.GetEnvFunc, cfg.GetWorkingDirFunc))
renderer.SetWidth(width)
renderer.SetContinuationPrompt(continuationPrompt)

return Model{
buffer: NewBuffer(),
keymap: keymap,
focused: true,
prompt: cfg.Prompt,
continuationPrompt: continuationPrompt,
historyValues: cfg.HistoryValues,
historyIndex: 0,
historySearch: NewHistorySearchState(),
Expand Down Expand Up @@ -288,6 +300,11 @@ func (m Model) Prompt() string {
return m.prompt
}

// ContinuationPrompt returns the continuation prompt for multi-line input.
func (m Model) ContinuationPrompt() string {
return m.continuationPrompt
}

// SetHistoryValues updates the history values for navigation.
func (m *Model) SetHistoryValues(values []string) {
m.historyValues = values
Expand Down Expand Up @@ -372,6 +389,9 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case ActionSubmit:
return m.handleSubmit()

case ActionInsertNewline:
return m.handleInsertNewline()

case ActionInterrupt:
return m.handleInterrupt()

Expand Down
15 changes: 10 additions & 5 deletions internal/repl/input/input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,19 +650,24 @@ func TestSanitizeRunes(t *testing.T) {
expected: []rune("hello world"),
},
{
name: "newline replaced with space",
name: "newline preserved",
input: []rune("hello\nworld"),
expected: []rune("hello world"),
expected: []rune("hello\nworld"),
},
{
name: "carriage return replaced with space",
name: "lone carriage return normalized to newline",
input: []rune("hello\rworld"),
expected: []rune("hello world"),
expected: []rune("hello\nworld"),
},
{
name: "CRLF normalized to newline",
input: []rune("hello\r\nworld"),
expected: []rune("hello\nworld"),
},
{
name: "multiple special chars",
input: []rune("a\tb\nc\rd"),
expected: []rune("a b c d"),
expected: []rune("a b\nc\nd"),
},
}

Expand Down
8 changes: 8 additions & 0 deletions internal/repl/input/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const (

// History search actions
ActionHistorySearchBackward // Start/continue reverse history search (Ctrl+R)

// Multi-line actions
ActionInsertNewline // Force-insert a newline (Alt+Enter)
)

// String returns the string representation of an Action.
Expand Down Expand Up @@ -103,6 +106,8 @@ func (a Action) String() string {
return "AcceptPrediction"
case ActionHistorySearchBackward:
return "HistorySearchBackward"
case ActionInsertNewline:
return "InsertNewline"
default:
return "Unknown"
}
Expand Down Expand Up @@ -182,6 +187,9 @@ func DefaultKeyMap() *KeyMap {

// History search
{Keys: []string{"ctrl+r"}, Action: ActionHistorySearchBackward},

// Multi-line
{Keys: []string{"alt+enter"}, Action: ActionInsertNewline},
})
}

Expand Down
Loading
Loading