diff --git a/cmd/gsh/defaults/starship.gsh b/cmd/gsh/defaults/starship.gsh index 9ead402..77328e9 100644 --- a/cmd/gsh/defaults/starship.gsh +++ b/cmd/gsh/defaults/starship.gsh @@ -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 @@ -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) } } diff --git a/docs/sdk/01-gsh-object.md b/docs/sdk/01-gsh-object.md index da3b633..e53940f 100644 --- a/docs/sdk/01-gsh-object.md +++ b/docs/sdk/01-gsh-object.md @@ -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) diff --git a/docs/sdk/05-events.md b/docs/sdk/05-events.md index 432374a..6d068c9 100644 --- a/docs/sdk/05-events.md +++ b/docs/sdk/05-events.md @@ -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` diff --git a/docs/tutorial/01-getting-started-with-gsh.md b/docs/tutorial/01-getting-started-with-gsh.md index 4c0f734..d031417 100644 --- a/docs/tutorial/01-getting-started-with-gsh.md +++ b/docs/tutorial/01-getting-started-with-gsh.md @@ -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 diff --git a/internal/appupdate/appupdate.go b/internal/appupdate/appupdate.go index 135a0b2..3d97cef 100644 --- a/internal/appupdate/appupdate.go +++ b/internal/appupdate/appupdate.go @@ -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" ) diff --git a/internal/history/history.go b/internal/history/history.go index c9add99..46de903 100644 --- a/internal/history/history.go +++ b/internal/history/history.go @@ -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" ) diff --git a/internal/repl/input/handlers.go b/internal/repl/input/handlers.go index 1754edd..2e2e7f7 100644 --- a/internal/repl/input/handlers.go +++ b/internal/repl/input/handlers.go @@ -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 } @@ -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() { @@ -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 diff --git a/internal/repl/input/highlight.go b/internal/repl/input/highlight.go index 0dd8283..db50984 100644 --- a/internal/repl/input/highlight.go +++ b/internal/repl/input/highlight.go @@ -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" ) @@ -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 == '$': @@ -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 } } @@ -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 { diff --git a/internal/repl/input/input.go b/internal/repl/input/input.go index e339a98..6686743 100644 --- a/internal/repl/input/input.go +++ b/internal/repl/input/input.go @@ -44,7 +44,8 @@ type Model struct { focused bool // Prompt - prompt string + prompt string + continuationPrompt string // History navigation historyValues []string @@ -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 @@ -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(), @@ -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 @@ -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() diff --git a/internal/repl/input/input_test.go b/internal/repl/input/input_test.go index 650fcaa..eed4db4 100644 --- a/internal/repl/input/input_test.go +++ b/internal/repl/input/input_test.go @@ -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"), }, } diff --git a/internal/repl/input/keymap.go b/internal/repl/input/keymap.go index 2e26d52..0ad41cd 100644 --- a/internal/repl/input/keymap.go +++ b/internal/repl/input/keymap.go @@ -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. @@ -103,6 +106,8 @@ func (a Action) String() string { return "AcceptPrediction" case ActionHistorySearchBackward: return "HistorySearchBackward" + case ActionInsertNewline: + return "InsertNewline" default: return "Unknown" } @@ -182,6 +187,9 @@ func DefaultKeyMap() *KeyMap { // History search {Keys: []string{"ctrl+r"}, Action: ActionHistorySearchBackward}, + + // Multi-line + {Keys: []string{"alt+enter"}, Action: ActionInsertNewline}, }) } diff --git a/internal/repl/input/multiline.go b/internal/repl/input/multiline.go new file mode 100644 index 0000000..4de1368 --- /dev/null +++ b/internal/repl/input/multiline.go @@ -0,0 +1,32 @@ +package input + +import ( + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +// IsInputComplete checks whether the given shell input is syntactically complete. +// Returns true if the input is a complete statement (or has a hard syntax error). +// Returns false if the input is incomplete and needs more input (unclosed quotes, +// pipes, heredocs, etc.). +func IsInputComplete(input string) bool { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return true + } + + // gsh agent commands (start with #) are always complete + if strings.HasPrefix(trimmed, "#") { + return true + } + + // Append a newline before parsing to properly detect heredocs and other + // constructs that require a newline to trigger IsIncomplete in mvdan/sh. + parser := syntax.NewParser() + _, err := parser.Parse(strings.NewReader(input+"\n"), "") + if err == nil { + return true + } + return !syntax.IsIncomplete(err) +} diff --git a/internal/repl/input/multiline_test.go b/internal/repl/input/multiline_test.go new file mode 100644 index 0000000..b882204 --- /dev/null +++ b/internal/repl/input/multiline_test.go @@ -0,0 +1,346 @@ +package input + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func TestIsInputComplete(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + // Complete: empty/whitespace + {"empty string", "", true}, + {"whitespace only", " ", true}, + + // Complete: simple commands + {"simple command", "ls", true}, + {"command with args", "echo hello", true}, + {"multiple commands", "foo; bar", true}, + {"command with newline", "echo hello\n", true}, + + // Incomplete: unclosed quotes + {"unclosed double quote", `echo "test`, false}, + {"unclosed single quote", "echo 'test", false}, + + // Incomplete: heredoc + {"heredoc not terminated", "cat < ', got %q", m.ContinuationPrompt()) + } +} + +func TestContinuationPromptCustom(t *testing.T) { + m := New(Config{ContinuationPrompt: "... "}) + if m.ContinuationPrompt() != "... " { + t.Errorf("expected custom continuation prompt '... ', got %q", m.ContinuationPrompt()) + } +} + +func TestRenderMultiLineInput(t *testing.T) { + config := DefaultRenderConfig() + // Use unstyled config for predictable output + config.PromptStyle = lipgloss.NewStyle() + config.TextStyle = lipgloss.NewStyle() + config.CursorStyle = lipgloss.NewStyle() + config.PredictionStyle = lipgloss.NewStyle() + + renderer := NewRenderer(config, NewHighlighter(nil, nil, nil)) + renderer.SetWidth(80) + renderer.SetContinuationPrompt("> ") + + // Multi-line input: "echo \"test\nsecond line" + buffer := NewBufferWithText("echo \"test\nsecond line") + + result := renderer.RenderInputLine("$ ", buffer, "", false) + lines := strings.Split(result, "\n") + + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got %d: %q", len(lines), result) + } + + // First line should start with the main prompt + if !strings.Contains(lines[0], "$ ") { + t.Errorf("first line should contain main prompt '$ ', got %q", lines[0]) + } + if !strings.Contains(lines[0], "echo") { + t.Errorf("first line should contain 'echo', got %q", lines[0]) + } + + // Second line should start with continuation prompt + if !strings.Contains(lines[1], "> ") { + t.Errorf("second line should contain continuation prompt '> ', got %q", lines[1]) + } + if !strings.Contains(lines[1], "second line") { + t.Errorf("second line should contain 'second line', got %q", lines[1]) + } +} + +func TestPredictionWithNewlineDiscarded(t *testing.T) { + // When a prediction suffix contains a newline, it should be discarded + // to prevent the cursor from landing on an invisible newline character. + config := DefaultRenderConfig() + config.PromptStyle = lipgloss.NewStyle() + config.CursorStyle = lipgloss.NewStyle().Reverse(true) + + renderer := NewRenderer(config, NewHighlighter(nil, nil, nil)) + renderer.SetWidth(80) + + // Single-line input with a prediction that contains a newline + buffer := NewBufferWithText("echo \"test") + prediction := "echo \"test\nmulti line input\"" + + result := renderer.RenderInputLine("$ ", buffer, prediction, true) + + // The prediction suffix should NOT appear (it contains \n) + if strings.Contains(result, "multi line") { + t.Errorf("prediction with newline should be discarded, got %q", result) + } + // The result should end with a space (cursor placeholder at end of text) + if !strings.HasSuffix(result, " ") { + t.Errorf("should have cursor space at end, got %q", result) + } +} + +func TestRenderMultiLineNoPrediction(t *testing.T) { + // Predictions should not appear for multi-line input + config := DefaultRenderConfig() + config.PromptStyle = lipgloss.NewStyle() + config.TextStyle = lipgloss.NewStyle() + config.CursorStyle = lipgloss.NewStyle().Reverse(true) + config.PredictionStyle = lipgloss.NewStyle() + + renderer := NewRenderer(config, NewHighlighter(nil, nil, nil)) + renderer.SetWidth(80) + renderer.SetContinuationPrompt("> ") + + buffer := NewBufferWithText("echo \"test\nsecond line") + + // Pass a prediction that starts with the full text + prediction := "echo \"test\nsecond line prediction" + result := renderer.RenderInputLine("$ ", buffer, prediction, true) + + // The prediction suffix should NOT appear in multi-line mode + if strings.Contains(result, "prediction") { + t.Errorf("predictions should not appear in multi-line mode, got %q", result) + } +} + +func TestSplitHighlightedByNewlines(t *testing.T) { + // Force ANSI output so highlighting produces escape codes in tests + oldProfile := lipgloss.DefaultRenderer().ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + h := NewHighlighter(nil, nil, nil) + text := "echo \"test\nanother line" + highlighted := h.Highlight(text) + t.Logf("Original: %q", text) + t.Logf("Highlighted: %q", highlighted) + + lines := splitHighlightedByNewlines(text, highlighted) + t.Logf("Split into %d lines:", len(lines)) + for i, line := range lines { + t.Logf(" line %d: %q", i, line) + } + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + // Second line should contain ANSI codes (string highlighting carried over) + if !strings.Contains(lines[1], "\x1b[") { + t.Errorf("second line should have ANSI highlighting, got %q", lines[1]) + } +} + +func TestRenderMultiLineSyntaxHighlightingCrossLine(t *testing.T) { + // Force ANSI output so highlighting produces escape codes in tests + oldProfile := lipgloss.DefaultRenderer().ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + config := DefaultRenderConfig() + config.PromptStyle = lipgloss.NewStyle() + config.TextStyle = lipgloss.NewStyle() + config.CursorStyle = lipgloss.NewStyle() + config.PredictionStyle = lipgloss.NewStyle() + + renderer := NewRenderer(config, NewHighlighter(nil, nil, nil)) + renderer.SetWidth(80) + renderer.SetContinuationPrompt("> ") + + // "echo \"test\nanother line" — the second line is inside the unclosed string + buffer := NewBufferWithText("echo \"test\nanother line") + result := renderer.RenderInputLine("$ ", buffer, "", false) + + resultLines := strings.Split(result, "\n") + if len(resultLines) < 2 { + t.Fatalf("expected at least 2 lines, got %d", len(resultLines)) + } + + // The second line should have ANSI escape codes (syntax highlighting) + // If highlighting is working correctly across lines, "another line" will + // be colored as a string (it's inside unclosed quotes). + // A broken implementation would have no ANSI codes on line 2. + if !strings.Contains(resultLines[1], "\x1b[") { + t.Errorf("second line should have ANSI highlighting (inside unclosed string), got %q", resultLines[1]) + } +} + +func TestRenderSingleLineUnchanged(t *testing.T) { + config := DefaultRenderConfig() + renderer := NewRenderer(config, NewHighlighter(nil, nil, nil)) + renderer.SetWidth(80) + renderer.SetContinuationPrompt("> ") + + buffer := NewBufferWithText("echo hello") + + result := renderer.RenderInputLine("$ ", buffer, "", false) + + // Single-line should not contain continuation prompt + if strings.Contains(result, "> ") { + t.Errorf("single-line input should not contain continuation prompt, got %q", result) + } +} + +func TestInsertNewlineKeyBinding(t *testing.T) { + km := DefaultKeyMap() + msg := tea.KeyMsg{Type: tea.KeyEnter, Alt: true} + action := km.Lookup(msg) + if action != ActionInsertNewline { + t.Errorf("expected ActionInsertNewline for alt+enter, got %v", action) + } +} diff --git a/internal/repl/input/prediction_history.go b/internal/repl/input/prediction_history.go index e60118b..2a8f665 100644 --- a/internal/repl/input/prediction_history.go +++ b/internal/repl/input/prediction_history.go @@ -13,6 +13,17 @@ import ( func (m Model) onTextChanged() (tea.Model, tea.Cmd) { text := m.buffer.Text() + // Disable predictions for multi-line input. + // Call OnInputChanged("") to cancel any in-flight prediction and bump the + // state ID so stale results are rejected by SetPrediction. + if strings.Contains(text, "\n") { + m.currentPrediction = "" + if m.prediction != nil { + m.prediction.OnInputChanged("") + } + return m, nil + } + // Check if prediction still applies if m.currentPrediction != "" && !strings.HasPrefix(m.currentPrediction, text) { m.currentPrediction = "" diff --git a/internal/repl/input/render.go b/internal/repl/input/render.go index df0b3d2..eeeab8e 100644 --- a/internal/repl/input/render.go +++ b/internal/repl/input/render.go @@ -4,9 +4,9 @@ package input import ( "strings" - "github.com/kunchenguid/gsh/internal/repl/render" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/kunchenguid/gsh/internal/repl/render" ) // RenderConfig holds styling configuration for rendering input components. @@ -52,9 +52,10 @@ func DefaultRenderConfig() RenderConfig { // Renderer handles rendering of input components. type Renderer struct { - config RenderConfig - width int - highlighter *Highlighter + config RenderConfig + width int + highlighter *Highlighter + continuationPrompt string } // NewRenderer creates a new Renderer with the given configuration. @@ -70,6 +71,19 @@ func NewRenderer(config RenderConfig, h *Highlighter) *Renderer { } } +// SetContinuationPrompt sets the prompt displayed on continuation lines for multi-line input. +func (r *Renderer) SetContinuationPrompt(prompt string) { + r.continuationPrompt = prompt +} + +// ContinuationPrompt returns the continuation prompt. +func (r *Renderer) ContinuationPrompt() string { + if r.continuationPrompt == "" { + return "> " + } + return r.continuationPrompt +} + // SetWidth sets the terminal width for rendering. func (r *Renderer) SetWidth(width int) { if width > 0 { @@ -95,6 +109,8 @@ func (r *Renderer) SetConfig(config RenderConfig) { // RenderInputLine renders the input line with prompt, text, cursor, and prediction. // It returns the rendered string for the input line, with automatic line wrapping // when the content exceeds the terminal width. +// For multi-line text (containing \n), it renders each line with the appropriate +// prompt (main prompt for the first line, continuation prompt for subsequent lines). func (r *Renderer) RenderInputLine(prompt string, buffer *Buffer, prediction string, focused bool) string { text := buffer.Text() pos := buffer.Pos() @@ -109,6 +125,11 @@ func (r *Renderer) RenderInputLine(prompt string, buffer *Buffer, prediction str pos = len(runes) } + // Multi-line input: render each line with its own prompt + if strings.Contains(text, "\n") { + return r.renderMultiLine(prompt, text, pos, prediction, focused) + } + // Build the content parts for wrapping // Only consider the last line of the prompt for width calculation, // since multi-line prompts are common and earlier lines don't affect wrapping @@ -119,46 +140,156 @@ func (r *Renderer) RenderInputLine(prompt string, buffer *Buffer, prediction str promptWidth := ansi.StringWidth(promptLastLine) // Calculate what text to render (including prediction suffix if applicable) + // Discard predictions containing newlines — they can't be rendered inline + // and would cause the cursor to land on an invisible newline character. var predictionSuffix string if pos >= len(runes) && prediction != "" && strings.HasPrefix(prediction, text) && len(prediction) > len(text) { - predictionRunes := []rune(prediction) - predictionSuffix = string(predictionRunes[len(runes):]) + suffix := string([]rune(prediction)[len(runes):]) + if !strings.Contains(suffix, "\n") { + predictionSuffix = suffix + } } // Use the wrapping renderer return r.renderWrappedInputLine(prompt, promptWidth, text, pos, predictionSuffix, focused) } -// renderWrappedInputLine renders the input line with wrapping support. -// It properly highlights the full text first, then handles wrapping and cursor positioning. -func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, text string, cursorPos int, predictionSuffix string, focused bool) string { +// renderMultiLine renders multi-line input with continuation prompts. +// The first line uses the main prompt; subsequent lines use the continuation prompt. +// Predictions are disabled for multi-line input. +// Syntax highlighting is performed on the full text to maintain cross-line context +// (e.g., unclosed quotes spanning multiple lines). +func (rndr *Renderer) renderMultiLine(prompt string, text string, cursorPos int, _ string, focused bool) string { + lines := strings.Split(text, "\n") + contPrompt := rndr.ContinuationPrompt() + + // Highlight the full text once for proper cross-line context + fullHighlighted := rndr.highlighter.Highlight(text) + highlightedLines := splitHighlightedByNewlines(text, fullHighlighted) + + var result strings.Builder + + // Track cumulative rune offset to determine which line the cursor is on + runeOffset := 0 + + for i, line := range lines { + if i > 0 { + result.WriteString("\n") + } + + // Choose prompt for this line + linePrompt := contPrompt + if i == 0 { + linePrompt = prompt + } + + // Calculate prompt width (last line only for multi-line prompts) + promptLastLine := linePrompt + if lastNewline := strings.LastIndex(linePrompt, "\n"); lastNewline != -1 { + promptLastLine = linePrompt[lastNewline+1:] + } + promptWidth := ansi.StringWidth(promptLastLine) + + lineRunes := []rune(line) + lineLen := len(lineRunes) + + // Determine cursor position relative to this line + lineCursorPos := cursorPos - runeOffset + + // Get pre-highlighted text for this line + var lineHighlighted string + if i < len(highlightedLines) { + lineHighlighted = highlightedLines[i] + } + + if lineCursorPos >= 0 && lineCursorPos <= lineLen { + // Cursor is on this line + result.WriteString(rndr.renderWrappedInputLinePreHighlighted(linePrompt, promptWidth, line, lineHighlighted, lineCursorPos, "", focused)) + } else { + // Cursor is not on this line — render text only, no cursor + // Pass cursorPos=-1 to indicate no cursor on this line + result.WriteString(rndr.renderWrappedInputLinePreHighlighted(linePrompt, promptWidth, line, lineHighlighted, -1, "", false)) + } + + // Advance offset past this line's runes + 1 for the \n separator + runeOffset += lineLen + 1 + } + + return result.String() +} + +// splitHighlightedByNewlines splits highlighted text at the newline positions of the +// original text, carrying ANSI state across line boundaries so each line chunk +// renders with the correct colors. +func splitHighlightedByNewlines(original string, highlighted string) []string { + origRunes := []rune(original) + hlRunes := []rune(highlighted) + + var lines []string + var currentLine strings.Builder + var activeStyle strings.Builder + + origIdx := 0 + hlIdx := 0 + + for origIdx < len(origRunes) && hlIdx < len(hlRunes) { + // Consume ANSI escape sequences from highlighted output + if hlRunes[hlIdx] == '\x1b' { + hlIdx = consumeANSISequence(hlRunes, hlIdx, ¤tLine, &activeStyle) + continue + } + + if origRunes[origIdx] == '\n' { + // End current line, start new one with carried-over ANSI state + lines = append(lines, currentLine.String()) + currentLine.Reset() + if activeStyle.Len() > 0 { + currentLine.WriteString(activeStyle.String()) + } + origIdx++ + hlIdx++ + continue + } + + currentLine.WriteRune(hlRunes[hlIdx]) + origIdx++ + hlIdx++ + } + + // Capture any remaining ANSI codes + for hlIdx < len(hlRunes) { + currentLine.WriteRune(hlRunes[hlIdx]) + hlIdx++ + } + + lines = append(lines, currentLine.String()) + return lines +} + +// renderWrappedInputLinePreHighlighted renders an input line using pre-highlighted text. +// This is used by renderMultiLine to avoid re-highlighting each line independently, +// preserving cross-line syntax context (e.g., unclosed strings spanning lines). +func (rndr *Renderer) renderWrappedInputLinePreHighlighted(prompt string, promptWidth int, text string, highlighted string, cursorPos int, predictionSuffix string, focused bool) string { runes := []rune(text) - hasCursorAtEnd := cursorPos >= len(runes) + // cursorPos < 0 means no cursor on this line + hasCursorAtEnd := cursorPos >= 0 && cursorPos >= len(runes) availableWidth := rndr.width if availableWidth <= 0 { availableWidth = 80 } - // Build the complete rendered content with proper highlighting var result strings.Builder - - // Start with the styled prompt styledPrompt := rndr.config.PromptStyle.Render(prompt) result.WriteString(styledPrompt) currentWidth := promptWidth if len(runes) > 0 { - // Highlight the full text first for proper syntax coloring context - // Then split the highlighted text at the cursor position - if cursorPos < len(runes) { - // Cursor is in the middle of the text - // We need to highlight the full text, then render with cursor inserted - currentWidth = rndr.appendTextWithWrappingAndCursor(&result, text, cursorPos, currentWidth, availableWidth, focused) + if cursorPos >= 0 && cursorPos < len(runes) { + currentWidth = rndr.appendPreHighlightedWithCursor(&result, text, highlighted, cursorPos, currentWidth, availableWidth, focused) } else { - // Cursor is at the end, highlight and render all text - currentWidth = rndr.appendTextWithWrapping(&result, text, currentWidth, availableWidth) + currentWidth = rndr.appendPreHighlightedWithWrapping(&result, text, highlighted, currentWidth, availableWidth) } } @@ -166,7 +297,6 @@ func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, tex if hasCursorAtEnd { predictionRunes := []rune(predictionSuffix) if len(predictionRunes) > 0 { - // Cursor on first prediction character firstPredChar := string(predictionRunes[0]) firstPredWidth := ansi.StringWidth(firstPredChar) @@ -184,11 +314,9 @@ func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, tex } currentWidth += firstPredWidth - // Rest of prediction with wrapping for _, pr := range predictionRunes[1:] { predCharStr := string(pr) predCharWidth := ansi.StringWidth(predCharStr) - if currentWidth+predCharWidth > availableWidth { result.WriteString("\n") currentWidth = 0 @@ -197,7 +325,6 @@ func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, tex currentWidth += predCharWidth } } else { - // No prediction, cursor on space if currentWidth+1 > availableWidth { result.WriteString("\n") } @@ -212,102 +339,42 @@ func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, tex return result.String() } -// appendTextWithWrappingAndCursor appends highlighted text with a cursor at the specified position. -// It highlights the full text first to maintain proper syntax coloring context. -func (rndr *Renderer) appendTextWithWrappingAndCursor(result *strings.Builder, text string, cursorPos int, currentWidth, availableWidth int, focused bool) int { +// appendPreHighlightedWithWrapping appends pre-highlighted text with line wrapping. +func (rndr *Renderer) appendPreHighlightedWithWrapping(result *strings.Builder, text string, highlighted string, currentWidth, availableWidth int) int { if text == "" { return currentWidth } runes := []rune(text) - cursorChar := string(runes[cursorPos]) - cursorCharWidth := ansi.StringWidth(cursorChar) - - // Highlight the entire text first for proper syntax coloring context - highlighted := rndr.highlighter.Highlight(text) - highlightedRunes := []rune(highlighted) var output strings.Builder width := currentWidth - textIdx := 0 // index in original runes - highlightIdx := 0 // index in highlighted runes + textIdx := 0 + highlightIdx := 0 - // Track the current ANSI style so we can re-apply it after line breaks var currentStyle strings.Builder for textIdx < len(runes) && highlightIdx < len(highlightedRunes) { - // Check if we're at an ANSI escape sequence in highlighted output if highlightedRunes[highlightIdx] == '\x1b' { - // Capture and output the entire escape sequence - var escSeq strings.Builder - for highlightIdx < len(highlightedRunes) { - ch := highlightedRunes[highlightIdx] - escSeq.WriteRune(ch) - output.WriteRune(ch) - highlightIdx++ - // ANSI sequences end with a letter - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') { - break - } - } - // Track the current style (reset clears it, other sequences update it) - seq := escSeq.String() - if seq == "\x1b[0m" || seq == "\x1b[m" { - currentStyle.Reset() - } else { - currentStyle.WriteString(seq) - } + highlightIdx = consumeANSISequence(highlightedRunes, highlightIdx, &output, ¤tStyle) continue } - // Get the current character from original text if textIdx >= len(runes) { break } - - // Check if this is the cursor position - if textIdx == cursorPos { - // Check if we need to wrap before the cursor character - if width+cursorCharWidth > availableWidth && width > 0 { - output.WriteRune('\n') - width = 0 - } - - // Render the cursor character with cursor style (overriding syntax highlighting) - if focused { - output.WriteString(rndr.config.CursorStyle.Render(cursorChar)) - } else { - // When not focused, show the character with its original highlighting - if highlightIdx < len(highlightedRunes) { - output.WriteRune(highlightedRunes[highlightIdx]) - } - } - highlightIdx++ - textIdx++ - width += cursorCharWidth - - // Re-apply the current style after cursor (since cursor style may have reset it) - if focused && currentStyle.Len() > 0 { - output.WriteString(currentStyle.String()) - } - continue - } - origChar := runes[textIdx] charWidth := ansi.StringWidth(string(origChar)) - // Check if we need to wrap before this character if width+charWidth > availableWidth && width > 0 { output.WriteRune('\n') - // Re-apply the current style after the line break if currentStyle.Len() > 0 { output.WriteString(currentStyle.String()) } width = 0 } - // Output the character from highlighted text if highlightIdx < len(highlightedRunes) { output.WriteRune(highlightedRunes[highlightIdx]) highlightIdx++ @@ -316,7 +383,6 @@ func (rndr *Renderer) appendTextWithWrappingAndCursor(result *strings.Builder, t width += charWidth } - // Output any remaining ANSI codes (like reset sequences) for highlightIdx < len(highlightedRunes) { output.WriteRune(highlightedRunes[highlightIdx]) highlightIdx++ @@ -326,75 +392,67 @@ func (rndr *Renderer) appendTextWithWrappingAndCursor(result *strings.Builder, t return width } -// appendTextWithWrapping appends highlighted text to the result with line wrapping. -// It returns the new current width after appending. -func (rndr *Renderer) appendTextWithWrapping(result *strings.Builder, text string, currentWidth, availableWidth int) int { +// appendPreHighlightedWithCursor appends pre-highlighted text with a cursor. +func (rndr *Renderer) appendPreHighlightedWithCursor(result *strings.Builder, text string, highlighted string, cursorPos int, currentWidth, availableWidth int, focused bool) int { if text == "" { return currentWidth } - // Highlight the entire text first for proper syntax coloring context - highlighted := rndr.highlighter.Highlight(text) - - // Now we need to insert line breaks at the right visual positions - // We'll walk through the original text to track visual width, - // and walk through the highlighted text to output it with breaks - runes := []rune(text) + cursorChar := string(runes[cursorPos]) + cursorCharWidth := ansi.StringWidth(cursorChar) + highlightedRunes := []rune(highlighted) var output strings.Builder width := currentWidth - textIdx := 0 // index in original runes - highlightIdx := 0 // index in highlighted runes + textIdx := 0 + highlightIdx := 0 - // Track the current ANSI style so we can re-apply it after line breaks var currentStyle strings.Builder for textIdx < len(runes) && highlightIdx < len(highlightedRunes) { - // Check if we're at an ANSI escape sequence in highlighted output if highlightedRunes[highlightIdx] == '\x1b' { - // Capture and output the entire escape sequence - var escSeq strings.Builder - for highlightIdx < len(highlightedRunes) { - ch := highlightedRunes[highlightIdx] - escSeq.WriteRune(ch) - output.WriteRune(ch) - highlightIdx++ - // ANSI sequences end with a letter - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') { - break - } - } - // Track the current style (reset clears it, other sequences update it) - seq := escSeq.String() - if seq == "\x1b[0m" || seq == "\x1b[m" { - currentStyle.Reset() - } else { - currentStyle.WriteString(seq) - } + highlightIdx = consumeANSISequence(highlightedRunes, highlightIdx, &output, ¤tStyle) continue } - // Get the current character from original text if textIdx >= len(runes) { break } + + if textIdx == cursorPos { + if width+cursorCharWidth > availableWidth && width > 0 { + output.WriteRune('\n') + width = 0 + } + if focused { + output.WriteString(rndr.config.CursorStyle.Render(cursorChar)) + } else { + if highlightIdx < len(highlightedRunes) { + output.WriteRune(highlightedRunes[highlightIdx]) + } + } + highlightIdx++ + textIdx++ + width += cursorCharWidth + if focused && currentStyle.Len() > 0 { + output.WriteString(currentStyle.String()) + } + continue + } + origChar := runes[textIdx] charWidth := ansi.StringWidth(string(origChar)) - // Check if we need to wrap before this character if width+charWidth > availableWidth && width > 0 { output.WriteRune('\n') - // Re-apply the current style after the line break if currentStyle.Len() > 0 { output.WriteString(currentStyle.String()) } width = 0 } - // Output the character from highlighted text - // The highlighted rune at this position should correspond to the original if highlightIdx < len(highlightedRunes) { output.WriteRune(highlightedRunes[highlightIdx]) highlightIdx++ @@ -403,7 +461,6 @@ func (rndr *Renderer) appendTextWithWrapping(result *strings.Builder, text strin width += charWidth } - // Output any remaining ANSI codes (like reset sequences) for highlightIdx < len(highlightedRunes) { output.WriteRune(highlightedRunes[highlightIdx]) highlightIdx++ @@ -413,6 +470,125 @@ func (rndr *Renderer) appendTextWithWrapping(result *strings.Builder, text strin return width } +// renderWrappedInputLine renders the input line with wrapping support. +// It properly highlights the full text first, then handles wrapping and cursor positioning. +func (rndr *Renderer) renderWrappedInputLine(prompt string, promptWidth int, text string, cursorPos int, predictionSuffix string, focused bool) string { + runes := []rune(text) + hasCursorAtEnd := cursorPos >= len(runes) + + availableWidth := rndr.width + if availableWidth <= 0 { + availableWidth = 80 + } + + // Build the complete rendered content with proper highlighting + var result strings.Builder + + // Start with the styled prompt + styledPrompt := rndr.config.PromptStyle.Render(prompt) + result.WriteString(styledPrompt) + + currentWidth := promptWidth + + if len(runes) > 0 { + // Highlight the full text first for proper syntax coloring context + // Then split the highlighted text at the cursor position + if cursorPos < len(runes) { + // Cursor is in the middle of the text + // We need to highlight the full text, then render with cursor inserted + currentWidth = rndr.appendTextWithWrappingAndCursor(&result, text, cursorPos, currentWidth, availableWidth, focused) + } else { + // Cursor is at the end, highlight and render all text + currentWidth = rndr.appendTextWithWrapping(&result, text, currentWidth, availableWidth) + } + } + + // Handle cursor at end of text + if hasCursorAtEnd { + predictionRunes := []rune(predictionSuffix) + if len(predictionRunes) > 0 { + // Cursor on first prediction character + firstPredChar := string(predictionRunes[0]) + firstPredWidth := ansi.StringWidth(firstPredChar) + + if currentWidth+firstPredWidth > availableWidth { + result.WriteString("\n") + currentWidth = 0 + } + + if focused { + result.WriteString(rndr.config.CursorStyle. + Foreground(rndr.config.PredictionStyle.GetForeground()). + Render(firstPredChar)) + } else { + result.WriteString(rndr.config.PredictionStyle.Render(firstPredChar)) + } + currentWidth += firstPredWidth + + // Rest of prediction with wrapping + for _, pr := range predictionRunes[1:] { + predCharStr := string(pr) + predCharWidth := ansi.StringWidth(predCharStr) + + if currentWidth+predCharWidth > availableWidth { + result.WriteString("\n") + currentWidth = 0 + } + result.WriteString(rndr.config.PredictionStyle.Render(predCharStr)) + currentWidth += predCharWidth + } + } else { + // No prediction, cursor on space + if currentWidth+1 > availableWidth { + result.WriteString("\n") + } + if focused { + result.WriteString(rndr.config.CursorStyle.Render(" ")) + } else { + result.WriteString(" ") + } + } + } + + return result.String() +} + +// appendTextWithWrappingAndCursor appends highlighted text with a cursor at the specified position. +// It highlights the full text first to maintain proper syntax coloring context. +func (rndr *Renderer) appendTextWithWrappingAndCursor(result *strings.Builder, text string, cursorPos int, currentWidth, availableWidth int, focused bool) int { + highlighted := rndr.highlighter.Highlight(text) + return rndr.appendPreHighlightedWithCursor(result, text, highlighted, cursorPos, currentWidth, availableWidth, focused) +} + +// appendTextWithWrapping appends highlighted text to the result with line wrapping. +// It returns the new current width after appending. +func (rndr *Renderer) appendTextWithWrapping(result *strings.Builder, text string, currentWidth, availableWidth int) int { + highlighted := rndr.highlighter.Highlight(text) + return rndr.appendPreHighlightedWithWrapping(result, text, highlighted, currentWidth, availableWidth) +} + +// consumeANSISequence reads a complete ANSI escape sequence starting at hlRunes[hlIdx], +// writes it to output, and updates currentStyle tracking. Returns the new hlIdx. +func consumeANSISequence(hlRunes []rune, hlIdx int, output *strings.Builder, currentStyle *strings.Builder) int { + var escSeq strings.Builder + for hlIdx < len(hlRunes) { + ch := hlRunes[hlIdx] + escSeq.WriteRune(ch) + output.WriteRune(ch) + hlIdx++ + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') { + break + } + } + seq := escSeq.String() + if seq == "\x1b[0m" || seq == "\x1b[m" { + currentStyle.Reset() + } else { + currentStyle.WriteString(seq) + } + return hlIdx +} + // RenderCompletionBox renders the completion suggestions in a box format. // maxVisible controls how many items are visible at once (scrolling window). func (r *Renderer) RenderCompletionBox(cs *CompletionState, maxVisible int) string { diff --git a/internal/repl/repl.go b/internal/repl/repl.go index b85f77a..470aa66 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -319,6 +319,7 @@ func (r *REPL) Run(ctx context.Context) error { } inputModel := input.New(input.Config{ Prompt: prompt, + ContinuationPrompt: r.getContinuationPrompt(), HistoryValues: historyValues, HistorySearchFunc: r.createHistorySearchFunc(), CompletionProvider: r.completionProvider, @@ -367,7 +368,13 @@ func (r *REPL) Run(ctx context.Context) error { case input.ResultSubmit: // Print the prompt + user input so it persists in terminal history // We use \r to return to start of line since Bubble Tea may leave cursor mid-line - fmt.Print("\r" + model.Prompt() + result.Value + "\n") + // For multi-line input, show continuation prompts on subsequent lines + lines := strings.Split(result.Value, "\n") + fmt.Print("\r" + model.Prompt() + lines[0]) + for _, line := range lines[1:] { + fmt.Print("\n" + model.ContinuationPrompt() + line) + } + fmt.Print("\n") // Process the command if err := r.processCommand(ctx, result.Value); err != nil { @@ -585,6 +592,19 @@ func (r *REPL) getPrompt() string { return "gsh> " } +// getContinuationPrompt returns the continuation prompt for multi-line input. +// It reads gsh.continuationPrompt which may have been set by event handlers (e.g., Starship). +func (r *REPL) getContinuationPrompt() string { + interp := r.executor.Interpreter() + replCtx := interp.SDKConfig().GetREPLContext() + if replCtx != nil && replCtx.ContinuationPromptValue != nil { + if strVal, ok := replCtx.ContinuationPromptValue.(*interpreter.StringValue); ok && strVal.Value != "" { + return strVal.Value + } + } + return "> " +} + // getHistoryValues returns recent history entries for navigation. func (r *REPL) getHistoryValues() []string { if r.history == nil { diff --git a/internal/script/interpreter/builtin_sdk.go b/internal/script/interpreter/builtin_sdk.go index 0c528f9..f36c9cf 100644 --- a/internal/script/interpreter/builtin_sdk.go +++ b/internal/script/interpreter/builtin_sdk.go @@ -51,6 +51,17 @@ func (i *Interpreter) registerGshSDK() { }, } + // Create gsh.continuationPrompt (dynamic, reads from REPL context) + continuationPromptObj := &DynamicValue{ + Get: func() Value { + replCtx := i.sdkConfig.GetREPLContext() + if replCtx == nil || replCtx.ContinuationPromptValue == nil { + return &StringValue{Value: ""} + } + return replCtx.ContinuationPromptValue + }, + } + // Create gsh.tools object with native tool implementations toolsObj := i.createNativeToolsObject() @@ -145,17 +156,18 @@ func (i *Interpreter) registerGshSDK() { gshObj := &GshObjectValue{ interp: i, baseProps: map[string]*PropertyDescriptor{ - "version": {Value: &StringValue{Value: i.version}, ReadOnly: true}, - "terminal": {Value: terminalObj, ReadOnly: true}, - "logging": {Value: loggingObj}, - "lastAgentRequest": {Value: lastAgentRequestObj, ReadOnly: true}, - "tools": {Value: toolsObj, ReadOnly: true}, - "ui": {Value: uiObj, ReadOnly: true}, - "models": {Value: modelsObj, ReadOnly: true}, - "lastCommand": {Value: lastCommandObj, ReadOnly: true}, - "history": {Value: historyObj, ReadOnly: true}, - "currentDirectory": {Value: currentDirectoryObj, ReadOnly: true}, - "prompt": {Value: promptObj}, + "version": {Value: &StringValue{Value: i.version}, ReadOnly: true}, + "terminal": {Value: terminalObj, ReadOnly: true}, + "logging": {Value: loggingObj}, + "lastAgentRequest": {Value: lastAgentRequestObj, ReadOnly: true}, + "tools": {Value: toolsObj, ReadOnly: true}, + "ui": {Value: uiObj, ReadOnly: true}, + "models": {Value: modelsObj, ReadOnly: true}, + "lastCommand": {Value: lastCommandObj, ReadOnly: true}, + "history": {Value: historyObj, ReadOnly: true}, + "currentDirectory": {Value: currentDirectoryObj, ReadOnly: true}, + "prompt": {Value: promptObj}, + "continuationPrompt": {Value: continuationPromptObj}, "use": {Value: &BuiltinValue{ Name: "gsh.use", Fn: i.builtinGshUse, @@ -644,6 +656,16 @@ func (g *GshObjectValue) SetProperty(name string, value Value) error { replCtx.PromptValue = promptStr } return nil + case "continuationPrompt": + cpStr, ok := value.(*StringValue) + if !ok { + return fmt.Errorf("gsh.continuationPrompt must be a string, got %s", value.Type()) + } + replCtx := g.interp.sdkConfig.GetREPLContext() + if replCtx != nil { + replCtx.ContinuationPromptValue = cpStr + } + return nil default: // For other properties, delegate to the underlying value's SetProperty if it has one if dv, ok := prop.Value.(*DynamicValue); ok { diff --git a/internal/script/interpreter/sdk.go b/internal/script/interpreter/sdk.go index c72a2de..628f07e 100644 --- a/internal/script/interpreter/sdk.go +++ b/internal/script/interpreter/sdk.go @@ -163,9 +163,10 @@ type SDKConfig struct { // REPLContext holds REPL-specific state that's available in the SDK type REPLContext struct { - LastCommand *REPLLastCommand - PromptValue Value // Prompt string set by event handlers (read/write via gsh.prompt) - Interpreter *Interpreter // Reference to interpreter for event execution + LastCommand *REPLLastCommand + PromptValue Value // Prompt string set by event handlers (read/write via gsh.prompt) + ContinuationPromptValue Value // Continuation prompt set by event handlers (read/write via gsh.continuationPrompt) + Interpreter *Interpreter // Reference to interpreter for event execution } // Models holds the model tier definitions (available in both REPL and script mode)