From c0350f5d9b679c501697292c21b6204cc1656c0e Mon Sep 17 00:00:00 2001 From: Luxferre Date: Thu, 10 Sep 2026 10:16:35 +0300 Subject: [PATCH] ux improvements --- main.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index a014720..3579a4b 100644 --- a/main.go +++ b/main.go @@ -1631,6 +1631,29 @@ func (e *editor) histNav(up bool) { e.pos = len(e.buf) } +// wordBack moves the cursor back to the start of the current/previous word. +func (e *editor) wordBack() { + for e.pos > 0 && unicode.IsSpace(e.buf[e.pos-1]) { e.pos-- } + for e.pos > 0 && !unicode.IsSpace(e.buf[e.pos-1]) { e.pos-- } +} + +// wordFwd moves the cursor forward to the end of the current/next word. +func (e *editor) wordFwd() { + n := len(e.buf) + for e.pos < n && unicode.IsSpace(e.buf[e.pos]) { e.pos++ } + for e.pos < n && !unicode.IsSpace(e.buf[e.pos]) { e.pos++ } +} + +// delWordBack deletes the word preceding the cursor (Emacs Ctrl+W). +func (e *editor) delWordBack() { + if e.pos == 0 { return } + start := e.pos + for start > 0 && unicode.IsSpace(e.buf[start-1]) { start-- } + for start > 0 && !unicode.IsSpace(e.buf[start-1]) { start-- } + e.buf = append(e.buf[:start], e.buf[e.pos:]...) + e.pos = start +} + func readPlain(prompt string) (string, bool) { fmt.Print(prompt) line, err := stdin.ReadString('\n') @@ -1681,6 +1704,25 @@ func readLine(prompt string) (string, bool) { fmt.Print("\r\n") return "", false } + // Ctrl+D with non-empty buffer: delete character under cursor + if e.pos < len(e.buf) { + e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...) + } + case 0x01: + e.pos = 0 // Ctrl+A: beginning of line + case 0x05: + e.pos = len(e.buf) // Ctrl+E: end of line + case 0x02: + if e.pos > 0 { e.pos-- } // Ctrl+B: backward char + case 0x06: + if e.pos < len(e.buf) { e.pos++ } // Ctrl+F: forward char + case 0x17: + e.delWordBack() // Ctrl+W: delete previous word + case 0x0b: + e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line + case 0x15: + e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line + e.pos = 0 case 0x7f, 0x08: if e.pos > 0 { e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...) @@ -1688,10 +1730,56 @@ func readLine(prompt string) (string, bool) { } case 0x1b: b1, err1 := stdin.ReadByte() - b2, err2 := stdin.ReadByte() - if err1 != nil || err2 != nil { continue } + if err1 != nil { continue } if b1 == '[' { + // Read the full CSI sequence (terminated by a byte in 0x40-0x7e). + var seq []byte + for { + b, err := stdin.ReadByte() + if err != nil { break } + seq = append(seq, b) + if b >= 0x40 && b <= 0x7e { break } + } + if len(seq) == 0 { continue } + last := seq[len(seq)-1] + switch last { + case 'A': + e.histNav(true) + case 'B': + e.histNav(false) + case 'C': + if strings.Contains(string(seq), "5") { + e.wordFwd() // Ctrl+Right + } else if e.pos < len(e.buf) { + e.pos++ + } + case 'D': + if strings.Contains(string(seq), "5") { + e.wordBack() // Ctrl+Left + } else if e.pos > 0 { + e.pos-- + } + case 'H': + e.pos = 0 // Home (ESC[H) + case 'F': + e.pos = len(e.buf) // End (ESC[F) + case '~': + // Home/End on some terminals: ESC[1~ / ESC[4~ + if len(seq) >= 1 && seq[0] == '1' { + e.pos = 0 + } else if len(seq) >= 1 && seq[0] == '4' { + e.pos = len(e.buf) + } + } + } else if b1 == 'O' { + // xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows) + b2, err2 := stdin.ReadByte() + if err2 != nil { continue } switch b2 { + case 'H': + e.pos = 0 + case 'F': + e.pos = len(e.buf) case 'A': e.histNav(true) case 'B': @@ -1808,6 +1896,17 @@ func main() { u = strings.TrimSpace(u) if u == "" { continue } addHistory(u) + // Command aliases: /model -> /cfg model, /endpoint -> /cfg endpoint. + // (Note: /models is a distinct command and is intentionally not matched.) + if u == "/model" { + u = "/cfg model" + } else if strings.HasPrefix(u, "/model ") { + u = "/cfg model " + strings.TrimSpace(strings.TrimPrefix(u, "/model ")) + } else if u == "/endpoint" { + u = "/cfg endpoint" + } else if strings.HasPrefix(u, "/endpoint ") { + u = "/cfg endpoint " + strings.TrimSpace(strings.TrimPrefix(u, "/endpoint ")) + } switch { case u == "/quit": goto done @@ -1911,6 +2010,8 @@ func main() { {"/load ", "load session"}, {"/compact", "compact context"}, {"/cfg [v]", "get/set config"}, + {"/model [m]", "alias for /cfg model"}, + {"/endpoint [e]", "alias for /cfg endpoint"}, {"!", "run shell command directly"}, {"/models", "list models at endpoint"}, {"/help", "show help"},