summaryrefslogtreecommitdiff
path: root/tui.go
diff options
context:
space:
mode:
Diffstat (limited to 'tui.go')
-rw-r--r--tui.go201
1 files changed, 164 insertions, 37 deletions
diff --git a/tui.go b/tui.go
index 8667314..3c53359 100644
--- a/tui.go
+++ b/tui.go
@@ -8,6 +8,7 @@ import (
_ "image/jpeg"
_ "image/png"
"os"
+ "os/exec"
"path"
"slices"
"strconv"
@@ -71,7 +72,7 @@ var (
[yellow]Ctrl+v[white]: switch between /completion and /chat api (if provided in config)
[yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server)
[yellow]Ctrl+t[white]: remove thinking (<think>) and tool messages from context (delete from chat)
-[yellow]Ctrl+l[white]: update connected model name (llamacpp)
+[yellow]Ctrl+l[white]: rotate through free OpenRouter models (if openrouter api) or update connected model name (llamacpp)
[yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg)
[yellow]Ctrl+j[white]: if chat agent is char.png will show the image; then any key to return
[yellow]Ctrl+a[white]: interrupt tts (needs tts server)
@@ -80,7 +81,13 @@ var (
[yellow]Ctrl+q[white]: cycle through mentioned chars in chat, to pick persona to send next msg as
[yellow]Ctrl+x[white]: cycle through mentioned chars in chat, to pick persona to send next msg as (for llm)
[yellow]Alt+5[white]: toggle fullscreen for input/chat window
+[yellow]Alt+1[white]: toggle shell mode (execute commands locally)
+=== scrolling chat window (some keys similar to vim) ===
+[yellow]arrows up/down and j/k[white]: scroll up and down
+[yellow]gg/G[white]: jump to the begging / end of the chat
+
+=== status line ===
%s
Press Enter to go back
@@ -204,6 +211,102 @@ func makePropsForm(props map[string]float32) *tview.Form {
return form
}
+func toggleShellMode() {
+ shellMode = !shellMode
+ if shellMode {
+ // Update input placeholder to indicate shell mode
+ textArea.SetPlaceholder("SHELL MODE: Enter command and press <Esc> to execute")
+ } else {
+ // Reset to normal mode
+ textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message. Alt+1 to exit shell mode")
+ }
+ updateStatusLine()
+}
+
+func executeCommandAndDisplay(cmdText string) {
+ // Parse the command (split by spaces, but handle quoted arguments)
+ cmdParts := parseCommand(cmdText)
+ if len(cmdParts) == 0 {
+ fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n")
+ textView.ScrollToEnd()
+ colorText()
+ return
+ }
+
+ command := cmdParts[0]
+ args := []string{}
+ if len(cmdParts) > 1 {
+ args = cmdParts[1:]
+ }
+
+ // Create the command execution
+ cmd := exec.Command(command, args...)
+
+ // Execute the command and get output
+ output, err := cmd.CombinedOutput()
+
+ // Add the command being executed to the chat
+ fmt.Fprintf(textView, "\n[yellow]$ %s[-:-:-]\n", cmdText)
+
+ if err != nil {
+ // Include both output and error
+ fmt.Fprintf(textView, "[red]Error: %s[-:-:-]\n", err.Error())
+ if len(output) > 0 {
+ fmt.Fprintf(textView, "[red]%s[-:-:-]\n", string(output))
+ }
+ } else {
+ // Only output if successful
+ if len(output) > 0 {
+ fmt.Fprintf(textView, "[green]%s[-:-:-]\n", string(output))
+ } else {
+ fmt.Fprintf(textView, "[green]Command executed successfully (no output)[-:-:-]\n")
+ }
+ }
+
+ // Scroll to end and update colors
+ textView.ScrollToEnd()
+ colorText()
+}
+
+// parseCommand splits command string handling quotes properly
+func parseCommand(cmd string) []string {
+ var args []string
+ var current string
+ var inQuotes bool
+ var quoteChar rune
+
+ for _, r := range cmd {
+ switch r {
+ case '"', '\'':
+ if inQuotes {
+ if r == quoteChar {
+ inQuotes = false
+ } else {
+ current += string(r)
+ }
+ } else {
+ inQuotes = true
+ quoteChar = r
+ }
+ case ' ', '\t':
+ if inQuotes {
+ current += string(r)
+ } else if current != "" {
+ args = append(args, current)
+ current = ""
+ }
+ default:
+ current += string(r)
+ }
+ }
+
+ if current != "" {
+ args = append(args, current)
+ }
+
+ return args
+}
+
func init() {
tview.Styles = colorschemes["default"]
app = tview.NewApplication()
@@ -575,10 +678,21 @@ func init() {
return nil
}
if event.Key() == tcell.KeyCtrlL {
- go func() {
- fetchLCPModelName() // blocks
+ // Check if the current API is an OpenRouter API
+ if strings.Contains(cfg.CurrentAPI, "openrouter.ai/api/v1/") {
+ // Rotate through OpenRouter free models
+ if len(ORFreeModels) > 0 {
+ currentORModelIndex = (currentORModelIndex + 1) % len(ORFreeModels)
+ chatBody.Model = ORFreeModels[currentORModelIndex]
+ }
updateStatusLine()
- }()
+ } else {
+ // For non-OpenRouter APIs, use the old logic
+ go func() {
+ fetchLCPModelName() // blocks
+ updateStatusLine()
+ }()
+ }
return nil
}
if event.Key() == tcell.KeyCtrlT {
@@ -812,46 +926,59 @@ func init() {
pages.AddPage(RAGLoadedPage, chatLoadedRAGTable, true, true)
return nil
}
+ if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '1' {
+ // Toggle shell mode: when enabled, commands are executed locally instead of sent to LLM
+ toggleShellMode()
+ return nil
+ }
// cannot send msg in editMode or botRespMode
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
- // read all text into buffer
msgText := textArea.GetText()
- nl := "\n"
- prevText := textView.GetText(true)
- persona := cfg.UserRole
- // strings.LastIndex()
- // newline is not needed is prev msg ends with one
- if strings.HasSuffix(prevText, nl) {
- nl = ""
- }
- if msgText != "" {
- // as what char user sends msg?
- if cfg.WriteNextMsgAs != "" {
- persona = cfg.WriteNextMsgAs
+
+ if shellMode && msgText != "" {
+ // In shell mode, execute command instead of sending to LLM
+ executeCommandAndDisplay(msgText)
+ textArea.SetText("", true) // Clear the input area
+ return nil
+ } else if !shellMode {
+ // Normal mode - send to LLM
+ nl := "\n"
+ prevText := textView.GetText(true)
+ persona := cfg.UserRole
+ // strings.LastIndex()
+ // newline is not needed is prev msg ends with one
+ if strings.HasSuffix(prevText, nl) {
+ nl = ""
}
- // check if plain text
- if !injectRole {
- matches := roleRE.FindStringSubmatch(msgText)
- if len(matches) > 1 {
- persona = matches[1]
- msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
+ if msgText != "" {
+ // as what char user sends msg?
+ if cfg.WriteNextMsgAs != "" {
+ persona = cfg.WriteNextMsgAs
}
+ // check if plain text
+ if !injectRole {
+ matches := roleRE.FindStringSubmatch(msgText)
+ if len(matches) > 1 {
+ persona = matches[1]
+ msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
+ }
+ }
+ // add user icon before user msg
+ fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
+ nl, len(chatBody.Messages), persona, msgText)
+ textArea.SetText("", true)
+ textView.ScrollToEnd()
+ colorText()
}
- // add user icon before user msg
- fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
- nl, len(chatBody.Messages), persona, msgText)
- textArea.SetText("", true)
- textView.ScrollToEnd()
- colorText()
+ go chatRound(msgText, persona, textView, false, false)
+ // Also clear any image attachment after sending the message
+ go func() {
+ // Wait a short moment for the message to be processed, then clear the image attachment
+ // This allows the image to be sent with the current message if it was attached
+ // But clears it for the next message
+ ClearImageAttachment()
+ }()
}
- go chatRound(msgText, persona, textView, false, false)
- // Also clear any image attachment after sending the message
- go func() {
- // Wait a short moment for the message to be processed, then clear the image attachment
- // This allows the image to be sent with the current message if it was attached
- // But clears it for the next message
- ClearImageAttachment()
- }()
return nil
}
if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {