diff options
| author | Grail Finder <wohilas@gmail.com> | 2025-11-28 14:29:52 +0300 |
|---|---|---|
| committer | Grail Finder <wohilas@gmail.com> | 2025-11-28 14:29:52 +0300 |
| commit | 860160ea0e4d940eee43da8f20538293612093a5 (patch) | |
| tree | 754ebf42161561c513651a51af53d98fe9fa9f2d | |
| parent | f5aab322afbf337d797a62f24cf85ef7766a96bb (diff) | |
Feat: shell mode
| -rw-r--r-- | helpfuncs.go | 11 | ||||
| -rw-r--r-- | main.go | 1 | ||||
| -rw-r--r-- | tui.go | 177 |
3 files changed, 155 insertions, 34 deletions
diff --git a/helpfuncs.go b/helpfuncs.go index f238cd4..d73befe 100644 --- a/helpfuncs.go +++ b/helpfuncs.go @@ -209,8 +209,17 @@ func makeStatusLine() string { } else { imageInfo = "" } + + // Add shell mode status to status line + var shellModeInfo string + if shellMode { + shellModeInfo = " | [green:-:b]SHELL MODE[-:-:-]" + } else { + shellModeInfo = "" + } + statusLine := fmt.Sprintf(indexLineCompletion, botRespMode, cfg.AssistantRole, activeChatName, cfg.ToolUse, chatBody.Model, cfg.SkipLLMResp, cfg.CurrentAPI, cfg.ThinkUse, logLevel.Level(), isRecording, persona, botPersona, injectRole) - return statusLine + imageInfo + return statusLine + imageInfo + shellModeInfo } @@ -15,6 +15,7 @@ var ( selectedIndex = int(-1) currentAPIIndex = 0 // Index to track current API in ApiLinks slice currentORModelIndex = 0 // Index to track current OpenRouter model in ORFreeModels slice + shellMode = false // indexLine = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | ThinkUse: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q)" indexLineCompletion = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | Insert <think>: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | Bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role_inject [orange:-:b]%v[-:-:-]" focusSwitcher = map[tview.Primitive]tview.Primitive{} @@ -8,6 +8,7 @@ import ( _ "image/jpeg" _ "image/png" "os" + "os/exec" "path" "slices" "strconv" @@ -78,6 +79,7 @@ var ( [yellow]Ctrl+y[white]: list loaded RAG files (view and manage loaded files) [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+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 @@ -207,6 +209,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() @@ -800,46 +898,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 { |
