feat(cli): support interactive start without initial prompt

This commit is contained in:
Leo 2026-02-12 11:15:39 +02:00
parent 981683a405
commit 01c90d587f
5 changed files with 172 additions and 10 deletions

View file

@ -86,6 +86,14 @@ This will:
Type `exit` or `quit` to end the conversation. 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 ## Development
Run without building: Run without building:

7
go.mod
View file

@ -2,4 +2,9 @@ module klod
go 1.21 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

4
go.sum
View file

@ -1,2 +1,6 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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=

145
main.go
View file

@ -15,6 +15,7 @@ import (
"unsafe" "unsafe"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"golang.org/x/term"
) )
type Message struct { type Message struct {
@ -117,6 +118,133 @@ func logConversation(message Message) error {
return nil 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 { func loadConfig() error {
// Try loading config from multiple locations in order // Try loading config from multiple locations in order
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
@ -209,23 +337,32 @@ func main() {
logConversation(assistantMsg) logConversation(assistantMsg)
} }
// Interactive conversation loop // Interactive conversation loop with bracketed paste support
reader := bufio.NewReader(os.Stdin) enableBracketedPaste()
defer disableBracketedPaste()
for { for {
fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m") fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m")
userInput, err := reader.ReadString('\n') userInput, err := readInputWithPasteSupport()
if err != nil { if err != nil {
if err == io.EOF {
fmt.Println("\nGoodbye!")
}
break break
} }
userInput = strings.TrimSpace(userInput) userInput = strings.TrimSpace(userInput)
// Check if user wants to exit // Check if user wants to exit
if userInput == "exit" || userInput == "quit" || userInput == "" { if userInput == "exit" || userInput == "quit" {
fmt.Println("Goodbye!") fmt.Println("Goodbye!")
break break
} }
if userInput == "" {
fmt.Println("Please enter a prompt (or type 'exit' to quit).")
continue
}
// Add user input to conversation history // Add user input to conversation history
userMsg := Message{ userMsg := Message{

18
main.sh
View file

@ -78,11 +78,15 @@ send_message() {
CONVERSATION_HISTORY+=("$response") CONVERSATION_HISTORY+=("$response")
} }
# Add initial user prompt to conversation history if [[ -n "$PROMPT" ]]; then
CONVERSATION_HISTORY+=("$PROMPT") # Add initial user prompt to conversation history
CONVERSATION_HISTORY+=("$PROMPT")
# Send initial message # Send initial message
send_message send_message
else
:
fi
# Conversation loop # Conversation loop
while true; do while true; do
@ -91,10 +95,14 @@ while true; do
read -e user_input read -e user_input
# Check if user wants to exit # 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!" echo "Goodbye!"
break break
fi fi
if [[ -z "$user_input" ]]; then
echo "Please enter a prompt (or type 'exit' to quit)."
continue
fi
# Add user input to conversation history # Add user input to conversation history
CONVERSATION_HISTORY+=("$user_input") CONVERSATION_HISTORY+=("$user_input")