3 Commits

Author SHA1 Message Date
Grail Finder
0d94734090 Enha: tool role index for shellmode 2026-02-27 08:07:55 +03:00
Grail Finder
a0ff384b81 Enha: shellmode within inputfield 2026-02-27 07:58:00 +03:00
Grail Finder
09b5e0d08f Enha: shell mode in filepickerdir 2026-02-26 20:10:00 +03:00
5 changed files with 107 additions and 58 deletions

View File

@@ -426,12 +426,11 @@ func deepseekModelValidator() error {
func toggleShellMode() { func toggleShellMode() {
shellMode = !shellMode shellMode = !shellMode
setShellMode(shellMode)
if shellMode { if shellMode {
// Update input placeholder to indicate shell mode shellInput.SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir))
textArea.SetPlaceholder("SHELL MODE: Enter command and press <Esc> to execute")
} else { } else {
// Reset to normal mode textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
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() updateStatusLine()
} }
@@ -443,15 +442,22 @@ func updateFlexLayout() {
} }
flex.Clear() flex.Clear()
flex.AddItem(textView, 0, 40, false) flex.AddItem(textView, 0, 40, false)
flex.AddItem(textArea, 0, 10, false) if shellMode {
flex.AddItem(shellInput, 0, 10, false)
} else {
flex.AddItem(textArea, 0, 10, false)
}
if positionVisible { if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false) flex.AddItem(statusLineWidget, 0, 2, false)
} }
// Keep focus on currently focused widget // Keep focus on currently focused widget
focused := app.GetFocus() focused := app.GetFocus()
if focused == textView { switch {
case focused == textView:
app.SetFocus(textView) app.SetFocus(textView)
} else { case shellMode:
app.SetFocus(shellInput)
default:
app.SetFocus(textArea) app.SetFocus(textArea)
} }
} }
@@ -474,10 +480,12 @@ func executeCommandAndDisplay(cmdText string) {
} }
// Create the command execution // Create the command execution
cmd := exec.Command(command, args...) cmd := exec.Command(command, args...)
cmd.Dir = cfg.FilePickerDir
// Execute the command and get output // Execute the command and get output
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Add the command being executed to the chat // Add the command being executed to the chat
fmt.Fprintf(textView, "\n[yellow]$ %s[-:-:-]\n", cmdText) fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
len(chatBody.Messages), cfg.ToolRole, cmdText)
var outputContent string var outputContent string
if err != nil { if err != nil {
// Include both output and error // Include both output and error
@@ -514,6 +522,11 @@ func executeCommandAndDisplay(cmdText string) {
textView.ScrollToEnd() textView.ScrollToEnd()
} }
colorText() colorText()
// Add command to history (avoid duplicates at the end)
if len(shellHistory) == 0 || shellHistory[len(shellHistory)-1] != cmdText {
shellHistory = append(shellHistory, cmdText)
}
shellHistoryPos = -1
} }
// parseCommand splits command string handling quotes properly // parseCommand splits command string handling quotes properly

View File

@@ -13,9 +13,11 @@ var (
injectRole = true injectRole = true
selectedIndex = int(-1) selectedIndex = int(-1)
shellMode = false shellMode = false
thinkingCollapsed = false shellHistory []string
statusLineTempl = "help (F12) | [%s:-:b]llm writes[-:-:-] (F6 to interrupt) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)" shellHistoryPos int = -1
focusSwitcher = map[tview.Primitive]tview.Primitive{} thinkingCollapsed = false
statusLineTempl = "help (F12) | [%s:-:b]llm writes[-:-:-] (F6 to interrupt) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
focusSwitcher = map[tview.Primitive]tview.Primitive{}
) )
func main() { func main() {

View File

@@ -343,7 +343,7 @@ func showBotRoleSelectionPopup() {
app.SetFocus(roleListWidget) app.SetFocus(roleListWidget)
} }
func showFileCompletionPopup(filter string) { func showShellFileCompletionPopup(filter string) {
baseDir := cfg.FilePickerDir baseDir := cfg.FilePickerDir
if baseDir == "" { if baseDir == "" {
baseDir = "." baseDir = "."
@@ -352,13 +352,12 @@ func showFileCompletionPopup(filter string) {
if len(complMatches) == 0 { if len(complMatches) == 0 {
return return
} }
// If only one match, auto-complete without showing popup
if len(complMatches) == 1 { if len(complMatches) == 1 {
currentText := textArea.GetText() currentText := shellInput.GetText()
atIdx := strings.LastIndex(currentText, "@") atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 { if atIdx >= 0 {
before := currentText[:atIdx] before := currentText[:atIdx]
textArea.SetText(before+complMatches[0], true) shellInput.SetText(before + complMatches[0])
} }
return return
} }
@@ -369,24 +368,24 @@ func showFileCompletionPopup(filter string) {
widget.AddItem(m, "", 0, nil) widget.AddItem(m, "", 0, nil)
} }
widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
currentText := textArea.GetText() currentText := shellInput.GetText()
atIdx := strings.LastIndex(currentText, "@") atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 { if atIdx >= 0 {
before := currentText[:atIdx] before := currentText[:atIdx]
textArea.SetText(before+mainText, true) shellInput.SetText(before + mainText)
} }
pages.RemovePage("fileCompletionPopup") pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(textArea) app.SetFocus(shellInput)
}) })
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape { if event.Key() == tcell.KeyEscape {
pages.RemovePage("fileCompletionPopup") pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(textArea) app.SetFocus(shellInput)
return nil return nil
} }
if event.Key() == tcell.KeyRune && event.Rune() == 'x' { if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage("fileCompletionPopup") pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(textArea) app.SetFocus(shellInput)
return nil return nil
} }
return event return event
@@ -400,8 +399,7 @@ func showFileCompletionPopup(filter string) {
AddItem(nil, 0, 1, false), width, 1, true). AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false) AddItem(nil, 0, 1, false)
} }
// Add modal page and make it visible pages.AddPage("shellFileCompletionPopup", modal(widget, 80, 20), true, true)
pages.AddPage("fileCompletionPopup", modal(widget, 80, 20), true, true)
app.SetFocus(widget) app.SetFocus(widget)
} }

View File

@@ -714,6 +714,7 @@ func executeCommand(args map[string]string) []byte {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, command, cmdArgs...) cmd := exec.CommandContext(ctx, command, cmdArgs...)
cmd.Dir = cfg.FilePickerDir
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
msg := fmt.Sprintf("command '%s' failed; error: %v; output: %s", command, err, string(output)) msg := fmt.Sprintf("command '%s' failed; error: %v; output: %s", command, err, string(output))

101
tui.go
View File

@@ -34,6 +34,7 @@ var (
indexPickWindow *tview.InputField indexPickWindow *tview.InputField
renameWindow *tview.InputField renameWindow *tview.InputField
roleEditWindow *tview.InputField roleEditWindow *tview.InputField
shellInput *tview.InputField
fullscreenMode bool fullscreenMode bool
positionVisible bool = true positionVisible bool = true
scrollToEndEnabled bool = true scrollToEndEnabled bool = true
@@ -124,46 +125,78 @@ Press <Enter> or 'x' to return
` `
) )
func setShellMode(enabled bool) {
shellMode = enabled
go func() {
app.QueueUpdateDraw(func() {
updateFlexLayout()
})
}()
}
func init() { func init() {
// Start background goroutine to update model color cache // Start background goroutine to update model color cache
startModelColorUpdater() startModelColorUpdater()
tview.Styles = colorschemes["default"] tview.Styles = colorschemes["default"]
app = tview.NewApplication() app = tview.NewApplication()
pages = tview.NewPages() pages = tview.NewPages()
textArea = tview.NewTextArea(). shellInput = tview.NewInputField().
SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.") SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
textArea.SetBorder(true).SetTitle("input") SetFieldWidth(0).
// Add input capture for @ completion SetDoneFunc(func(key tcell.Key) {
textArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if key == tcell.KeyEnter {
cmd := shellInput.GetText()
if cmd != "" {
executeCommandAndDisplay(cmd)
}
shellInput.SetText("")
}
})
// Copy your file completion logic to shellInput's InputCapture
shellInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if !shellMode { if !shellMode {
return event return event
} }
// Handle Tab key for file completion // Handle Up arrow for history previous
if event.Key() == tcell.KeyTab { if event.Key() == tcell.KeyUp {
currentText := textArea.GetText() if len(shellHistory) > 0 {
row, col, _, _ := textArea.GetCursor() if shellHistoryPos < len(shellHistory)-1 {
// Calculate absolute position from row/col shellHistoryPos++
lines := strings.Split(currentText, "\n") shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
cursorPos := 0
for i := 0; i < row && i < len(lines); i++ {
cursorPos += len(lines[i]) + 1 // +1 for newline
}
cursorPos += col
// Look backwards from cursor to find @
if cursorPos > 0 {
// Find the last @ before cursor
textBeforeCursor := currentText[:cursorPos]
atIndex := strings.LastIndex(textBeforeCursor, "@")
if atIndex >= 0 {
// Extract the partial match text after @
filter := textBeforeCursor[atIndex+1:]
showFileCompletionPopup(filter)
return nil // Consume the Tab event
} }
} }
return nil
}
// Handle Down arrow for history next
if event.Key() == tcell.KeyDown {
if shellHistoryPos > 0 {
shellHistoryPos--
shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
} else if shellHistoryPos == 0 {
shellHistoryPos = -1
shellInput.SetText("")
}
return nil
}
// Reset history position when user types
if event.Key() == tcell.KeyRune {
shellHistoryPos = -1
}
// Handle Tab key for @ file completion
if event.Key() == tcell.KeyTab {
currentText := shellInput.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
filter := currentText[atIndex+1:]
showShellFileCompletionPopup(filter)
}
return nil
} }
return event return event
}) })
textArea = tview.NewTextArea().
SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
textArea.SetBorder(true).SetTitle("input")
textView = tview.NewTextView(). textView = tview.NewTextView().
SetDynamicColors(true). SetDynamicColors(true).
SetRegions(true). SetRegions(true).
@@ -948,14 +981,16 @@ func init() {
} }
// cannot send msg in editMode or botRespMode // cannot send msg in editMode or botRespMode
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode { if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
msgText := textArea.GetText() if shellMode {
if shellMode && msgText != "" { cmdText := shellInput.GetText()
// In shell mode, execute command instead of sending to LLM if cmdText != "" {
executeCommandAndDisplay(msgText) executeCommandAndDisplay(cmdText)
textArea.SetText("", true) // Clear the input area shellInput.SetText("")
}
return nil return nil
} else if !shellMode { }
// Normal mode - send to LLM msgText := textArea.GetText()
if msgText != "" {
nl := "\n\n" // keep empty lines between messages nl := "\n\n" // keep empty lines between messages
prevText := textView.GetText(true) prevText := textView.GetText(true)
persona := cfg.UserRole persona := cfg.UserRole