From 01c90d587fc9113e4027cd5feb654e6a70a963ac Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 12 Feb 2026 11:15:39 +0200 Subject: [PATCH] feat(cli): support interactive start without initial prompt --- README.md | 8 +++ go.mod | 7 ++- go.sum | 4 ++ main.go | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- main.sh | 18 +++++-- 5 files changed, 172 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b8fb5b0..436b81f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ This will: Type `exit` or `quit` to end the conversation. +You can also start without an initial prompt: + +```bash +klod +``` + +This starts the service and asks for your first prompt interactively. + ## Development Run without building: diff --git a/go.mod b/go.mod index 9959061..bc90ade 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module klod go 1.21 -require github.com/joho/godotenv v1.5.1 +require ( + github.com/joho/godotenv v1.5.1 + golang.org/x/term v0.27.0 +) + +require golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum index d61b19e..cb825d7 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= diff --git a/main.go b/main.go index 97c5470..ac6628d 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "unsafe" "github.com/joho/godotenv" + "golang.org/x/term" ) type Message struct { @@ -117,6 +118,133 @@ func logConversation(message Message) error { return nil } +// enableBracketedPaste sends escape sequence to enable bracketed paste mode +func enableBracketedPaste() { + fmt.Print("\033[?2004h") +} + +// disableBracketedPaste sends escape sequence to disable bracketed paste mode +func disableBracketedPaste() { + fmt.Print("\033[?2004l") +} + +// readInputWithPasteSupport reads user input with support for multi-line paste +// It uses raw terminal mode to detect bracketed paste sequences +func readInputWithPasteSupport() (string, error) { + fd := int(os.Stdin.Fd()) + + // Check if stdin is a terminal + if !term.IsTerminal(fd) { + // Fall back to simple readline for non-terminal input + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + return strings.TrimSpace(line), err + } + + // Save terminal state and switch to raw mode + oldState, err := term.MakeRaw(fd) + if err != nil { + // Fall back to simple readline if raw mode fails + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + return strings.TrimSpace(line), err + } + defer term.Restore(fd, oldState) + + var input strings.Builder + var buf [1]byte + inPaste := false + escBuf := make([]byte, 0, 10) + + for { + n, err := os.Stdin.Read(buf[:]) + if err != nil || n == 0 { + return input.String(), err + } + + b := buf[0] + + // Handle escape sequences + if b == 0x1b { // ESC + escBuf = append(escBuf[:0], b) + continue + } + + if len(escBuf) > 0 { + escBuf = append(escBuf, b) + + // Check for bracketed paste start: ESC[200~ + if len(escBuf) == 6 && escBuf[0] == 0x1b && escBuf[1] == '[' && + escBuf[2] == '2' && escBuf[3] == '0' && escBuf[4] == '0' && escBuf[5] == '~' { + inPaste = true + escBuf = escBuf[:0] + continue + } + + // Check for bracketed paste end: ESC[201~ + if len(escBuf) == 6 && escBuf[0] == 0x1b && escBuf[1] == '[' && + escBuf[2] == '2' && escBuf[3] == '0' && escBuf[4] == '1' && escBuf[5] == '~' { + inPaste = false + escBuf = escBuf[:0] + continue + } + + // If sequence is getting too long without matching, it's not a paste sequence + // Write accumulated bytes and reset + if len(escBuf) > 6 { + for _, eb := range escBuf { + if eb >= 0x20 { + input.WriteByte(eb) + fmt.Print(string(eb)) + } + } + escBuf = escBuf[:0] + } + continue + } + + // Handle special keys + switch b { + case '\r', '\n': // Enter + if inPaste { + input.WriteByte('\n') + fmt.Print("\r\n") // Echo newline + } else { + fmt.Print("\r\n") + return input.String(), nil + } + case 0x03: // Ctrl+C + fmt.Print("^C\r\n") + return "", fmt.Errorf("interrupted") + case 0x04: // Ctrl+D + if input.Len() == 0 { + return "", io.EOF + } + return input.String(), nil + case 0x7f, 0x08: // Backspace/Delete + s := input.String() + if len(s) > 0 { + // Handle multi-byte UTF-8 characters + runes := []rune(s) + if len(runes) > 0 { + input.Reset() + input.WriteString(string(runes[:len(runes)-1])) + fmt.Print("\b \b") // Erase character on screen + } + } + default: + if b >= 0x20 || b == '\t' { // Printable character or tab + input.WriteByte(b) + if b == '\t' { + fmt.Print(" ") // Display tab as spaces + } else { + fmt.Print(string(b)) // Echo character + } + } + } + } +} + func loadConfig() error { // Try loading config from multiple locations in order homeDir, err := os.UserHomeDir() @@ -209,23 +337,32 @@ func main() { logConversation(assistantMsg) } - // Interactive conversation loop - reader := bufio.NewReader(os.Stdin) + // Interactive conversation loop with bracketed paste support + enableBracketedPaste() + defer disableBracketedPaste() + for { fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m") - userInput, err := reader.ReadString('\n') + userInput, err := readInputWithPasteSupport() if err != nil { + if err == io.EOF { + fmt.Println("\nGoodbye!") + } break } userInput = strings.TrimSpace(userInput) // Check if user wants to exit - if userInput == "exit" || userInput == "quit" || userInput == "" { + if userInput == "exit" || userInput == "quit" { fmt.Println("Goodbye!") break } + if userInput == "" { + fmt.Println("Please enter a prompt (or type 'exit' to quit).") + continue + } // Add user input to conversation history userMsg := Message{ diff --git a/main.sh b/main.sh index 8694851..5a3b460 100755 --- a/main.sh +++ b/main.sh @@ -78,11 +78,15 @@ send_message() { CONVERSATION_HISTORY+=("$response") } -# Add initial user prompt to conversation history -CONVERSATION_HISTORY+=("$PROMPT") +if [[ -n "$PROMPT" ]]; then + # Add initial user prompt to conversation history + CONVERSATION_HISTORY+=("$PROMPT") -# Send initial message -send_message + # Send initial message + send_message +else + : +fi # Conversation loop while true; do @@ -91,10 +95,14 @@ while true; do read -e user_input # Check if user wants to exit - if [[ "$user_input" == "exit" ]] || [[ "$user_input" == "quit" ]] || [[ -z "$user_input" ]]; then + if [[ "$user_input" == "exit" ]] || [[ "$user_input" == "quit" ]]; then echo "Goodbye!" break fi + if [[ -z "$user_input" ]]; then + echo "Please enter a prompt (or type 'exit' to quit)." + continue + fi # Add user input to conversation history CONVERSATION_HISTORY+=("$user_input") -- 2.45.2