27 Commits

Author SHA1 Message Date
Grail Finder
ef53e9bebe Enha: json tag for stats 2026-02-23 09:35:40 +03:00
Grail Finder
a546bfe596 Enha: defer finalizeRespStats 2026-02-23 09:30:37 +03:00
Grail Finder
23c21f87bb Feat: show stats 2026-02-23 09:18:19 +03:00
Grail Finder
850ca103e5 Enha: update model status color 2026-02-22 19:06:45 +03:00
Grail Finder
b7b5fcbf79 Fix: tts skiping over sentences 2026-02-22 18:27:32 +03:00
Grail Finder
1e13c7796d Fix: character specific context tts 2026-02-22 17:45:09 +03:00
Grail Finder
9a727b21ad Enha: simplify status line 2026-02-22 17:27:24 +03:00
Grail Finder
beb944c390 Enha: filepicker dir as coders dir 2026-02-22 16:17:30 +03:00
Grail Finder
5844dd1494 Fix: doc link 2026-02-22 08:08:16 +03:00
Grail Finder
84c4010213 Doc: config doc update 2026-02-22 08:04:27 +03:00
Grail Finder
86260e218c Enha: moke (loaded) indicator prefix instead of a suffix 2026-02-22 07:41:09 +03:00
Grail Finder
2c694e2b2b Enha: add (loaded) suffix if model is loaded 2026-02-21 20:42:43 +03:00
Grail Finder
66ccb7a732 Fix: collapsing thinking while thinking 2026-02-21 20:28:12 +03:00
Grail Finder
deece322ef Fix: collapse live thinking removing role 2026-02-21 20:24:15 +03:00
Grail Finder
e7c8fef32d Feat: collapse thinking during gen 2026-02-21 17:10:58 +03:00
Grail Finder
eedda0ec4b Feat (pull/18994): llama.cpp reasoning 2026-02-21 16:31:59 +03:00
Grail Finder
96ffbd5cf5 Feat: openrouter reasoning 2026-02-21 16:26:13 +03:00
Grail Finder
85b11fa9ff Chore: status line, linter complaints 2026-02-21 10:15:36 +03:00
Grail Finder
1675af98d4 Chore: colorschemes to their own file 2026-02-21 09:55:08 +03:00
Grail Finder
61a0ddfdfd Fix: stop making http request per each keypress 2026-02-20 20:11:52 +03:00
Grail Finder
26ab5c59e3 Enha: scroll helppage with jk 2026-02-20 13:17:27 +03:00
Grail Finder
35cc8c068f Fix: force update statusline on colorchange 2026-02-20 12:14:45 +03:00
Grail Finder
27fdec1361 Enha: textarea color update 2026-02-19 14:22:43 +03:00
Grail Finder
76827a71cc Enha: colorscheme to change background color 2026-02-19 12:21:25 +03:00
Grail Finder
3a9a7dbe99 Feat: colorscheme popup [WIP] 2026-02-19 10:36:04 +03:00
Grail Finder
d3361c13c5 Enha (shell): tabcompletion depth and limit 2026-02-19 10:14:16 +03:00
Grail Finder
7c1a8b0122 Chore: cleaner tool use log 2026-02-19 09:00:50 +03:00
20 changed files with 660 additions and 236 deletions

View File

@@ -8,7 +8,7 @@ made with use of [tview](https://github.com/rivo/tview)
- tts/stt (run make commands to get deps);
- image input;
- function calls (function calls are implemented natively, to avoid calling outside sources);
- [character specific context (unique feature)](char-specific-context.md)
- [character specific context (unique feature)](docs/char-specific-context.md)
#### how it looks
![how it looks](assets/ex01.png)

View File

@@ -110,8 +110,8 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
req := models.NewDSChatReq(*chatBody)
return json.Marshal(req)
case isOpenRouter:
// OpenRouter chat
req := models.NewOpenRouterChatReq(*chatBody, defaultProps)
// OpenRouter chat - agents don't use reasoning by default
req := models.NewOpenRouterChatReq(*chatBody, defaultProps, "")
return json.Marshal(req)
default:
// Assume llama.cpp chat (OpenAI format)

206
bot.go
View File

@@ -46,6 +46,7 @@ var (
ragger *rag.RAG
chunkParser ChunkParser
lastToolCall *models.FuncCall
lastRespStats *models.ResponseStats
//nolint:unused // TTS_ENABLED conditionally uses this
orator Orator
asr STT
@@ -403,6 +404,23 @@ func fetchLCPModels() ([]string, error) {
return localModels, nil
}
// fetchLCPModelsWithLoadStatus returns models with "(loaded)" indicator for loaded models
func fetchLCPModelsWithLoadStatus() ([]string, error) {
models, err := fetchLCPModelsWithStatus()
if err != nil {
return nil, err
}
result := make([]string, 0, len(models.Data))
for _, m := range models.Data {
modelName := m.ID
if m.Status.Value == "loaded" {
modelName = "(loaded) " + modelName
}
result = append(result, modelName)
}
return result, nil
}
// fetchLCPModelsWithStatus returns the full LCPModels struct including status information.
func fetchLCPModelsWithStatus() (*models.LCPModels, error) {
resp, err := http.Get(cfg.FetchModelNameAPI)
@@ -456,6 +474,7 @@ func monitorModelLoad(modelID string) {
if err := notifyUser("model loaded", "Model "+modelID+" is now loaded and ready."); err != nil {
logger.Debug("failed to notify user", "error", err)
}
refreshChatDisplay()
return
}
}
@@ -466,30 +485,28 @@ func monitorModelLoad(modelID string) {
// extractDetailedErrorFromBytes extracts detailed error information from response body bytes
func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
// Try to parse as JSON to extract detailed error information
var errorResponse map[string]interface{}
var errorResponse map[string]any
if err := json.Unmarshal(body, &errorResponse); err == nil {
// Check if it's an error response with detailed information
if errorData, ok := errorResponse["error"]; ok {
if errorMap, ok := errorData.(map[string]interface{}); ok {
if errorMap, ok := errorData.(map[string]any); ok {
var errorMsg string
if msg, ok := errorMap["message"]; ok {
errorMsg = fmt.Sprintf("%v", msg)
}
var details []string
if code, ok := errorMap["code"]; ok {
details = append(details, fmt.Sprintf("Code: %v", code))
}
if metadata, ok := errorMap["metadata"]; ok {
// Handle metadata which might contain raw error details
if metadataMap, ok := metadata.(map[string]interface{}); ok {
if metadataMap, ok := metadata.(map[string]any); ok {
if raw, ok := metadataMap["raw"]; ok {
// Parse the raw error string if it's JSON
var rawError map[string]interface{}
var rawError map[string]any
if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil {
if rawErrorData, ok := rawError["error"]; ok {
if rawErrorMap, ok := rawErrorData.(map[string]interface{}); ok {
if rawErrorMap, ok := rawErrorData.(map[string]any); ok {
if rawMsg, ok := rawErrorMap["message"]; ok {
return fmt.Sprintf("API Error: %s", rawMsg)
}
@@ -500,20 +517,30 @@ func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
}
details = append(details, fmt.Sprintf("Metadata: %v", metadata))
}
if len(details) > 0 {
return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", "))
}
return "API Error: " + errorMsg
}
}
}
// If not a structured error response, return the raw body with status
return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body))
}
func finalizeRespStats(tokenCount int, startTime time.Time) {
duration := time.Since(startTime).Seconds()
var tps float64
if duration > 0 {
tps = float64(tokenCount) / duration
}
lastRespStats = &models.ResponseStats{
Tokens: tokenCount,
Duration: duration,
TokensPerSec: tps,
}
}
// sendMsgToLLM expects streaming resp
func sendMsgToLLM(body io.Reader) {
choseChunkParser()
@@ -568,10 +595,17 @@ func sendMsgToLLM(body io.Reader) {
streamDone <- true
return
}
//
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
counter := uint32(0)
tokenCount := 0
startTime := time.Now()
hasReasoning := false
reasoningSent := false
defer func() {
finalizeRespStats(tokenCount, startTime)
}()
for {
var (
answerText string
@@ -644,10 +678,16 @@ func sendMsgToLLM(body io.Reader) {
// break
// }
if chunk.Finished {
// Close the thinking block if we were streaming reasoning and haven't closed it yet
if hasReasoning && !reasoningSent {
chunkChan <- "</think>"
tokenCount++
}
if chunk.Chunk != "" {
logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter)
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
chunkChan <- answerText
tokenCount++
}
streamDone <- true
break
@@ -655,6 +695,30 @@ func sendMsgToLLM(body io.Reader) {
if counter == 0 {
chunk.Chunk = strings.TrimPrefix(chunk.Chunk, " ")
}
// Handle reasoning chunks - stream them immediately as they arrive
if chunk.Reasoning != "" && !reasoningSent {
if !hasReasoning {
// First reasoning chunk - send opening tag
chunkChan <- "<think>"
tokenCount++
hasReasoning = true
}
// Stream reasoning content immediately
answerText = strings.ReplaceAll(chunk.Reasoning, "\n\n", "\n")
if answerText != "" {
chunkChan <- answerText
tokenCount++
}
}
// When we get content and have been streaming reasoning, close the thinking block
if chunk.Chunk != "" && hasReasoning && !reasoningSent {
// Close the thinking block before sending actual content
chunkChan <- "</think>"
tokenCount++
reasoningSent = true
}
// bot sends way too many \n
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
// Accumulate text to check for stop strings that might span across chunks
@@ -664,8 +728,12 @@ func sendMsgToLLM(body io.Reader) {
slices.Contains(stopStrings, answerText) {
logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText)
streamDone <- true
break
}
if answerText != "" {
chunkChan <- answerText
tokenCount++
}
openAIToolChan <- chunk.ToolChunk
if chunk.FuncName != "" {
lastToolCall.Name = chunk.FuncName
@@ -763,6 +831,7 @@ func chatWatcher(ctx context.Context) {
func chatRound(r *models.ChatRoundReq) error {
botRespMode = true
updateStatusLine()
botPersona := cfg.AssistantRole
if cfg.WriteNextMsgAsCompletionAgent != "" {
botPersona = cfg.WriteNextMsgAsCompletionAgent
@@ -780,23 +849,78 @@ func chatRound(r *models.ChatRoundReq) error {
}
go sendMsgToLLM(reader)
logger.Debug("looking at vars in chatRound", "msg", r.UserMsg, "regen", r.Regen, "resume", r.Resume)
msgIdx := len(chatBody.Messages)
if !r.Resume {
fmt.Fprintf(textView, "\n[-:-:b](%d) ", len(chatBody.Messages))
// Add empty message to chatBody immediately so it persists during Alt+T toggle
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
Role: botPersona, Content: "",
})
fmt.Fprintf(textView, "\n[-:-:b](%d) ", msgIdx)
fmt.Fprint(textView, roleToIcon(botPersona))
fmt.Fprint(textView, "[-:-:-]\n")
if cfg.ThinkUse && !strings.Contains(cfg.CurrentAPI, "v1") {
// fmt.Fprint(textView, "<think>")
chunkChan <- "<think>"
}
} else {
msgIdx = len(chatBody.Messages) - 1
}
respText := strings.Builder{}
toolResp := strings.Builder{}
// Variables for handling thinking blocks during streaming
inThinkingBlock := false
thinkingBuffer := strings.Builder{}
justExitedThinkingCollapsed := false
out:
for {
select {
case chunk := <-chunkChan:
// Handle thinking blocks during streaming
if strings.HasPrefix(chunk, "<think>") && !inThinkingBlock {
// Start of thinking block
inThinkingBlock = true
thinkingBuffer.Reset()
thinkingBuffer.WriteString(chunk)
if thinkingCollapsed {
// Show placeholder immediately when thinking starts in collapsed mode
fmt.Fprint(textView, "[yellow::i][thinking... (press Alt+T to expand)][-:-:-]")
if scrollToEndEnabled {
textView.ScrollToEnd()
}
respText.WriteString(chunk)
continue
}
} else if inThinkingBlock {
thinkingBuffer.WriteString(chunk)
if strings.Contains(chunk, "</think>") {
// End of thinking block
inThinkingBlock = false
if thinkingCollapsed {
// Thinking already displayed as placeholder, just update respText
respText.WriteString(chunk)
justExitedThinkingCollapsed = true
if scrollToEndEnabled {
textView.ScrollToEnd()
}
continue
}
// If not collapsed, fall through to normal display
} else if thinkingCollapsed {
// Still in thinking block and collapsed - just buffer, don't display
respText.WriteString(chunk)
continue
}
// If not collapsed, fall through to normal display
}
// Add spacing after collapsed thinking block before real response
if justExitedThinkingCollapsed {
chunk = "\n\n" + chunk
justExitedThinkingCollapsed = false
}
fmt.Fprint(textView, chunk)
respText.WriteString(chunk)
// Update the message in chatBody.Messages so it persists during Alt+T
chatBody.Messages[msgIdx].Content = respText.String()
if scrollToEndEnabled {
textView.ScrollToEnd()
}
@@ -811,7 +935,6 @@ out:
textView.ScrollToEnd()
}
case <-streamDone:
// drain any remaining chunks from chunkChan before exiting
for len(chunkChan) > 0 {
chunk := <-chunkChan
fmt.Fprint(textView, chunk)
@@ -820,34 +943,41 @@ out:
textView.ScrollToEnd()
}
if cfg.TTS_ENABLED {
// Send chunk to audio stream handler
TTSTextChan <- chunk
}
}
if cfg.TTS_ENABLED {
// msg is done; flush it down
TTSFlushChan <- true
}
break out
}
}
var msgStats *models.ResponseStats
if lastRespStats != nil {
msgStats = &models.ResponseStats{
Tokens: lastRespStats.Tokens,
Duration: lastRespStats.Duration,
TokensPerSec: lastRespStats.TokensPerSec,
}
lastRespStats = nil
}
botRespMode = false
// numbers in chatbody and displayed must be the same
if r.Resume {
chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String()
// lastM.Content = lastM.Content + respText.String()
// Process the updated message to check for known_to tags in resumed response
updatedMsg := chatBody.Messages[len(chatBody.Messages)-1]
processedMsg := processMessageTag(&updatedMsg)
chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg
} else {
newMsg := models.RoleMsg{
Role: botPersona, Content: respText.String(),
if msgStats != nil && chatBody.Messages[len(chatBody.Messages)-1].Role != cfg.ToolRole {
chatBody.Messages[len(chatBody.Messages)-1].Stats = msgStats
}
// Process the new message to check for known_to tags in LLM response
newMsg = *processMessageTag(&newMsg)
chatBody.Messages = append(chatBody.Messages, newMsg)
stopTTSIfNotForUser(&newMsg)
} else {
chatBody.Messages[msgIdx].Content = respText.String()
processedMsg := processMessageTag(&chatBody.Messages[msgIdx])
chatBody.Messages[msgIdx] = *processedMsg
if msgStats != nil && chatBody.Messages[msgIdx].Role != cfg.ToolRole {
chatBody.Messages[msgIdx].Stats = msgStats
}
stopTTSIfNotForUser(&chatBody.Messages[msgIdx])
}
cleanChatBody()
refreshChatDisplay()
@@ -1070,7 +1200,7 @@ func findCall(msg, toolCall string) bool {
}
resp := callToolWithAgent(fc.Name, fc.Args)
toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting
logger.Info("llm used tool call", "tool_resp", toolMsg, "tool_attrs", fc)
logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg)
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
"\n\n", len(chatBody.Messages), cfg.ToolRole, toolMsg)
// Create tool response message with the proper tool_call_id
@@ -1110,8 +1240,26 @@ func chatToText(messages []models.RoleMsg, showSys bool) string {
// Collapse thinking blocks if enabled
if thinkingCollapsed {
placeholder := "[yellow::i][thinking... (press Alt+T to expand)][-:-:-]"
text = thinkRE.ReplaceAllString(text, placeholder)
text = thinkRE.ReplaceAllStringFunc(text, func(match string) string {
// Extract content between <think> and </think>
start := len("<think>")
end := len(match) - len("</think>")
if start < end && start < len(match) {
content := match[start:end]
return fmt.Sprintf("[yellow::i][thinking... (%d chars) (press Alt+T to expand)][-:-:-]", len(content))
}
return "[yellow::i][thinking... (press Alt+T to expand)][-:-:-]"
})
// Handle incomplete thinking blocks (during streaming when </think> hasn't arrived yet)
if strings.Contains(text, "<think>") && !strings.Contains(text, "</think>") {
// Find the incomplete thinking block and replace it
startIdx := strings.Index(text, "<think>")
if startIdx != -1 {
content := text[startIdx+len("<think>"):]
placeholder := fmt.Sprintf("[yellow::i][thinking... (%d chars) (press Alt+T to expand)][-:-:-]", len(content))
text = text[:startIdx] + placeholder
}
}
}
return text
@@ -1250,7 +1398,7 @@ func init() {
var err error
cfg, err = config.LoadConfig("config.toml")
if err != nil {
fmt.Println("failed to load config.toml")
fmt.Println("failed to load config.toml", err)
cancel()
os.Exit(1)
return

63
colors.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
var (
colorschemes = map[string]tview.Theme{
"default": tview.Theme{
PrimitiveBackgroundColor: tcell.ColorDefault,
ContrastBackgroundColor: tcell.ColorGray,
MoreContrastBackgroundColor: tcell.ColorSteelBlue,
BorderColor: tcell.ColorGray,
TitleColor: tcell.ColorRed,
GraphicsColor: tcell.ColorBlue,
PrimaryTextColor: tcell.ColorLightGray,
SecondaryTextColor: tcell.ColorYellow,
TertiaryTextColor: tcell.ColorOrange,
InverseTextColor: tcell.ColorPurple,
ContrastSecondaryTextColor: tcell.ColorLime,
},
"gruvbox": tview.Theme{
PrimitiveBackgroundColor: tcell.NewHexColor(0x282828), // Background: #282828 (dark gray)
ContrastBackgroundColor: tcell.ColorDarkGoldenrod, // Selected option: warm yellow (#b57614)
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark grayish-blue (#32302f)
BorderColor: tcell.ColorLightGray, // Light gray (#a89984)
TitleColor: tcell.ColorRed, // Red (#fb4934)
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#689d6a)
PrimaryTextColor: tcell.ColorLightGray, // Light gray (#d5c4a1)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#fabd2f)
TertiaryTextColor: tcell.ColorOrange, // Orange (#fe8019)
InverseTextColor: tcell.ColorWhite, // White (#f9f5d7) for selected text
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#b8bb26)
},
"solarized": tview.Theme{
PrimitiveBackgroundColor: tcell.NewHexColor(0x002b36), // Background: #002b36 (base03)
ContrastBackgroundColor: tcell.ColorDarkCyan, // Selected option: cyan (#2aa198)
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark blue (#073642)
BorderColor: tcell.ColorLightBlue, // Light blue (#839496)
TitleColor: tcell.ColorRed, // Red (#dc322f)
GraphicsColor: tcell.ColorBlue, // Blue (#268bd2)
PrimaryTextColor: tcell.ColorWhite, // White (#fdf6e3)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#b58900)
TertiaryTextColor: tcell.ColorOrange, // Orange (#cb4b16)
InverseTextColor: tcell.ColorWhite, // White (#eee8d5) for selected text
ContrastSecondaryTextColor: tcell.ColorLightCyan, // Light cyan (#93a1a1)
},
"dracula": tview.Theme{
PrimitiveBackgroundColor: tcell.NewHexColor(0x282a36), // Background: #282a36
ContrastBackgroundColor: tcell.ColorDarkMagenta, // Selected option: magenta (#bd93f9)
MoreContrastBackgroundColor: tcell.ColorDarkGray, // Non-selected options: dark gray (#44475a)
BorderColor: tcell.ColorLightGray, // Light gray (#f8f8f2)
TitleColor: tcell.ColorRed, // Red (#ff5555)
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#8be9fd)
PrimaryTextColor: tcell.ColorWhite, // White (#f8f8f2)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#f1fa8c)
TertiaryTextColor: tcell.ColorOrange, // Orange (#ffb86c)
InverseTextColor: tcell.ColorWhite, // White (#f8f8f2) for selected text
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#50fa7b)
},
}
)

View File

@@ -10,7 +10,10 @@ DeepSeekModel = "deepseek-reasoner"
OpenRouterCompletionAPI = "https://openrouter.ai/api/v1/completions"
OpenRouterChatAPI = "https://openrouter.ai/api/v1/chat/completions"
# OpenRouterToken = ""
# embeddings
EmbedURL = "http://localhost:8082/v1/embeddings"
HFToken = false
#
ShowSys = true
LogFile = "log.txt"
UserRole = "user"
@@ -21,6 +24,7 @@ ChunkLimit = 100000
AutoScrollEnabled = true
AutoCleanToolCallsFromCtx = false
# rag settings
RAGEnabled = false
RAGBatchSize = 1
RAGWordLimit = 80
RAGWorkers = 2
@@ -41,12 +45,15 @@ STT_LANG = "en" # Language for speech recognition (for WHISPER_BINARY mode)
STT_SR = 16000 # Sample rate for audio recording
#
DBPATH = "gflt.db"
FilePickerDir = "." # Directory where file picker should start
FilePickerDir = "." # Directory for file picker start and coding assistant file operations (relative paths resolved against this)
FilePickerExts = "png,jpg,jpeg,gif,webp" # Comma-separated list of allowed file extensions for file picker
CodingDir = "." # Default directory for coding assistant file operations (relative paths resolved against this)
EnableMouse = false # Enable mouse support in the UI
# character specific context
CharSpecificContextEnabled = true
CharSpecificContextTag = "@"
AutoTurn = true
StripThinkingFromAPI = true # Strip <think> blocks from messages before sending to LLM (keeps them in chat history)
# OpenRouter reasoning configuration (only applies to OpenRouter chat API)
# Valid values: xhigh, high, medium, low, minimal, none (empty or none = disabled)
# Models that support reasoning will include thinking content wrapped in <think> tags
ReasoningEffort = "medium"

View File

@@ -20,6 +20,7 @@ type Config struct {
ToolUse bool `toml:"ToolUse"`
ThinkUse bool `toml:"ThinkUse"`
StripThinkingFromAPI bool `toml:"StripThinkingFromAPI"`
ReasoningEffort string `toml:"ReasoningEffort"`
AssistantRole string `toml:"AssistantRole"`
SysDir string `toml:"SysDir"`
ChunkLimit uint32 `toml:"ChunkLimit"`
@@ -31,15 +32,14 @@ type Config struct {
DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"`
CodingDir string `toml:"CodingDir"`
ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"`
// embeddings
RAGEnabled bool `toml:"RAGEnabled"`
EmbedURL string `toml:"EmbedURL"`
HFToken string `toml:"HFToken"`
RAGDir string `toml:"RAGDir"`
// rag settings
RAGEnabled bool `toml:"RAGEnabled"`
RAGDir string `toml:"RAGDir"`
RAGWorkers uint32 `toml:"RAGWorkers"`
RAGBatchSize int `toml:"RAGBatchSize"`
RAGWordLimit uint32 `toml:"RAGWordLimit"`

View File

@@ -140,17 +140,24 @@ This document explains how to set up and configure the application using the `co
- Path to the SQLite database file used for storing conversation history and other data.
#### FilePickerDir (`"."`)
- Directory where the file (image) picker should start when selecting files.
#### FilePickerExts (`"png,jpg,jpeg,gif,webp"`)
- Comma-separated list of allowed file extensions for the file picker.
#### CodingDir (`"."`)
- Default directory for coding assistant file operations. Relative paths in file tools (file_read, file_write, etc.) are resolved against this directory. Use absolute paths (starting with `/`) to bypass this.
- Directory where the file picker starts and where relative paths in coding assistant file tools (file_read, file_write, etc.) are resolved against. Use absolute paths (starting with `/`) to bypass this.
#### EnableMouse (`false`)
- Enable or disable mouse support in the UI. When set to `true`, allows clicking buttons and interacting with UI elements using the mouse, but prevents the terminal from handling mouse events normally (such as selecting and copying text). When set to `false`, enables default terminal behavior allowing you to select and copy text, but disables mouse interaction with UI elements.
### Character-Specific Context Settings (/completion only)
[character specific context page for more info](./char-specific-context.md)
#### CharSpecificContextEnabled (`true`)
- Enable or disable character-specific context functionality.
#### CharSpecificContextTag (`"@"`)
- The tag prefix used to reference character-specific context in messages.
#### AutoTurn (`true`)
- Enable or disable automatic turn detection/switching.
### Additional Features
Those could be switched in program, but also bould be setup in config.
@@ -159,7 +166,13 @@ Those could be switched in program, but also bould be setup in config.
- Enable or disable explanation of tools to llm, so it could use them.
#### ThinkUse
- Enable or disable insertion of <think> token at the beggining of llm resp.
- Enable or disable insertion of JsonSerializerToken at the beggining of llm resp.
### StripThinkingFromAPI (`true`)
- Strip thinking blocks from messages before sending to LLM. Keeps them in chat history for local viewing but reduces token usage in API calls.
#### ReasoningEffort (`"medium"`)
- OpenRouter reasoning configuration (only applies to OpenRouter chat API). Valid values: `xhigh`, `high`, `medium`, `low`, `minimal`, `none`. Empty or `none` disables reasoning.
## Environment Variables

View File

@@ -92,8 +92,6 @@ func (o *KokoroOrator) stoproutine() {
func (o *KokoroOrator) readroutine() {
tokenizer, _ := english.NewSentenceTokenizer(nil)
// var sentenceBuf bytes.Buffer
// var remainder strings.Builder
for {
select {
case chunk := <-TTSTextChan:
@@ -106,24 +104,28 @@ func (o *KokoroOrator) readroutine() {
continue
}
text := o.textBuffer.String()
o.mu.Unlock()
sentences := tokenizer.Tokenize(text)
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
for i, sentence := range sentences {
if i == len(sentences)-1 { // last sentence
o.mu.Lock()
o.textBuffer.Reset()
_, err := o.textBuffer.WriteString(sentence.Text)
if len(sentences) <= 1 {
o.mu.Unlock()
if err != nil {
o.logger.Warn("failed to write to stringbuilder", "error", err)
continue
}
continue // if only one (often incomplete) sentence; wait for next chunk
completeSentences := sentences[:len(sentences)-1]
remaining := sentences[len(sentences)-1].Text
o.textBuffer.Reset()
o.textBuffer.WriteString(remaining)
o.mu.Unlock()
for _, sentence := range completeSentences {
o.mu.Lock()
interrupted := o.interrupt
o.mu.Unlock()
if interrupted {
return
}
cleanedText := models.CleanText(sentence.Text)
if cleanedText == "" {
continue // Skip empty text after cleaning
continue
}
o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
if err := o.Speak(cleanedText); err != nil {
@@ -338,24 +340,28 @@ func (o *GoogleTranslateOrator) readroutine() {
continue
}
text := o.textBuffer.String()
o.mu.Unlock()
sentences := tokenizer.Tokenize(text)
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
for i, sentence := range sentences {
if i == len(sentences)-1 { // last sentence
o.mu.Lock()
o.textBuffer.Reset()
_, err := o.textBuffer.WriteString(sentence.Text)
if len(sentences) <= 1 {
o.mu.Unlock()
if err != nil {
o.logger.Warn("failed to write to stringbuilder", "error", err)
continue
}
continue // if only one (often incomplete) sentence; wait for next chunk
completeSentences := sentences[:len(sentences)-1]
remaining := sentences[len(sentences)-1].Text
o.textBuffer.Reset()
o.textBuffer.WriteString(remaining)
o.mu.Unlock()
for _, sentence := range completeSentences {
o.mu.Lock()
interrupted := o.interrupt
o.mu.Unlock()
if interrupted {
return
}
cleanedText := models.CleanText(sentence.Text)
if cleanedText == "" {
continue // Skip empty text after cleaning
continue
}
o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
if err := o.Speak(cleanedText); err != nil {

View File

@@ -9,8 +9,10 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"
"time"
"unicode"
"math/rand/v2"
@@ -18,6 +20,46 @@ import (
"github.com/rivo/tview"
)
// Cached model color - updated by background goroutine
var cachedModelColor string = "orange"
// startModelColorUpdater starts a background goroutine that periodically updates
// the cached model color. Only runs HTTP requests for local llama.cpp APIs.
func startModelColorUpdater() {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Initial check
updateCachedModelColor()
for range ticker.C {
updateCachedModelColor()
}
}()
}
// updateCachedModelColor updates the global cachedModelColor variable
func updateCachedModelColor() {
if !isLocalLlamacpp() {
cachedModelColor = "orange"
return
}
// Check if model is loaded
loaded, err := isModelLoaded(chatBody.Model)
if err != nil {
// On error, assume not loaded (red)
cachedModelColor = "red"
return
}
if loaded {
cachedModelColor = "green"
} else {
cachedModelColor = "red"
}
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > unicode.MaxASCII {
@@ -60,19 +102,23 @@ func refreshChatDisplay() {
displayText := chatToText(filteredMessages, cfg.ShowSys)
textView.SetText(displayText)
colorText()
updateStatusLine()
if scrollToEndEnabled {
textView.ScrollToEnd()
}
}
// stopTTSIfNotForUser: character specific context, not meant fot the human to hear
func stopTTSIfNotForUser(msg *models.RoleMsg) {
if strings.Contains(cfg.CurrentAPI, "/chat") || !cfg.CharSpecificContextEnabled {
return
}
viewingAs := cfg.UserRole
if cfg.WriteNextMsgAs != "" {
viewingAs = cfg.WriteNextMsgAs
}
// stop tts if msg is not for user
if cfg.CharSpecificContextEnabled &&
!slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
if !slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
TTSDoneChan <- true
}
}
@@ -130,8 +176,8 @@ func colorText() {
}
func updateStatusLine() {
statusLineWidget.SetText(makeStatusLine())
helpView.SetText(fmt.Sprintf(helpText, makeStatusLine()))
status := makeStatusLine()
statusLineWidget.SetText(status)
}
func initSysCards() ([]string, error) {
@@ -273,22 +319,11 @@ func isLocalLlamacpp() bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// getModelColor returns the color tag for the model name based on its load status.
// getModelColor returns the cached color tag for the model name.
// The cached value is updated by a background goroutine every 5 seconds.
// For non-local models, returns orange. For local llama.cpp models, returns green if loaded, red if not.
func getModelColor() string {
if !isLocalLlamacpp() {
return "orange"
}
// Check if model is loaded
loaded, err := isModelLoaded(chatBody.Model)
if err != nil {
// On error, assume not loaded (red)
return "red"
}
if loaded {
return "green"
}
return "red"
return cachedModelColor
}
func makeStatusLine() string {
@@ -322,10 +357,19 @@ func makeStatusLine() string {
}
// Get model color based on load status for local llama.cpp models
modelColor := getModelColor()
statusLine := fmt.Sprintf(indexLineCompletion, boolColors[botRespMode], botRespMode, activeChatName,
boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona,
botPersona, boolColors[injectRole], injectRole)
statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], activeChatName,
boolColors[cfg.ToolUse], modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
cfg.CurrentAPI, persona, botPersona)
if cfg.STT_ENABLED {
recordingS := fmt.Sprintf(" | [%s:-:b]voice recording[-:-:-] (ctrl+r)",
boolColors[isRecording])
statusLine += recordingS
}
// completion endpoint
if !strings.Contains(cfg.CurrentAPI, "chat") {
roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole])
statusLine += roleInject
}
return statusLine + imageInfo + shellModeInfo
}
@@ -706,23 +750,44 @@ func searchPrev() {
// == tab completion ==
func scanFiles(dir, filter string) []string {
const maxDepth = 3
const maxFiles = 50
var files []string
entries, err := os.ReadDir(dir)
var scanRecursive func(currentDir string, currentDepth int, relPath string)
scanRecursive = func(currentDir string, currentDepth int, relPath string) {
if len(files) >= maxFiles {
return
}
if currentDepth > maxDepth {
return
}
entries, err := os.ReadDir(currentDir)
if err != nil {
return files
return
}
for _, entry := range entries {
if len(files) >= maxFiles {
return
}
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
if filter == "" || strings.HasPrefix(strings.ToLower(name), strings.ToLower(filter)) {
fullPath := name
if relPath != "" {
fullPath = relPath + "/" + name
}
if entry.IsDir() {
files = append(files, name+"/")
} else {
files = append(files, name)
// Recursively scan subdirectories
scanRecursive(filepath.Join(currentDir, name), currentDepth+1, fullPath)
continue
}
// Check if file matches filter
if filter == "" || strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(filter)) {
files = append(files, fullPath)
}
}
}
scanRecursive(dir, 0, "")
return files
}

18
llm.go
View File

@@ -237,8 +237,10 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
return &models.TextChunk{Finished: true}, nil
}
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content,
Chunk: lastChoice.Delta.Content,
Reasoning: lastChoice.Delta.ReasoningContent,
}
// Check for tool calls in all choices, not just the last one
@@ -256,7 +258,7 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
}
}
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
if lastChoice.FinishReason == "stop" {
if resp.Chunk != "" {
logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
}
@@ -614,12 +616,14 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
logger.Error("failed to decode", "error", err, "line", string(data))
return nil, err
}
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
resp := &models.TextChunk{
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content,
Chunk: lastChoice.Delta.Content,
Reasoning: lastChoice.Delta.Reasoning,
}
// Handle tool calls similar to LCPChat
if len(llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls) > 0 {
toolCall := llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls[0]
if len(lastChoice.Delta.ToolCalls) > 0 {
toolCall := lastChoice.Delta.ToolCalls[0]
resp.ToolChunk = toolCall.Function.Arguments
fname := toolCall.Function.Name
if fname != "" {
@@ -631,7 +635,7 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
if resp.ToolChunk != "" {
resp.ToolResp = true
}
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
if lastChoice.FinishReason == "stop" {
if resp.Chunk != "" {
logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
}
@@ -710,7 +714,7 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
}
// Clean null/empty messages to prevent API issues
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps)
orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps, cfg.ReasoningEffort)
if cfg.ToolUse && !resume && role != cfg.ToolRole {
orBody.Tools = baseTools // set tools to use
}

View File

@@ -13,7 +13,7 @@ var (
selectedIndex = int(-1)
shellMode = false
thinkingCollapsed = false
indexLineCompletion = "F12 to show keys help | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role injection (alt+7) [%s:-:b]%v[-:-:-]"
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{}
)

View File

@@ -65,6 +65,7 @@ type LLMRespChunk struct {
Index int `json:"index"`
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []ToolDeltaResp `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
@@ -86,6 +87,7 @@ type TextChunk struct {
ToolResp bool
FuncName string
ToolID string
Reasoning string // For models that send reasoning separately (OpenRouter, etc.)
}
type TextContentPart struct {
@@ -108,6 +110,7 @@ type RoleMsg struct {
ContentParts []any `json:"-"`
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
KnownTo []string `json:"known_to,omitempty"`
Stats *ResponseStats `json:"stats"`
hasContentParts bool // Flag to indicate which content type to marshal
}
@@ -181,13 +184,11 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
}
func (m *RoleMsg) ToText(i int) string {
// Convert content to string representation
var contentStr string
var imageIndicators []string
if !m.hasContentParts {
contentStr = m.Content
} else {
// For structured content, collect text parts and image indicators
var textParts []string
for _, part := range m.ContentParts {
switch p := part.(type) {
@@ -196,7 +197,6 @@ func (m *RoleMsg) ToText(i int) string {
textParts = append(textParts, p.Text)
}
case ImageContentPart:
// Collect image indicator
displayPath := p.Path
if displayPath == "" {
displayPath = "image"
@@ -214,7 +214,6 @@ func (m *RoleMsg) ToText(i int) string {
}
}
case "image_url":
// Handle unmarshaled image content
var displayPath string
if pathVal, pathExists := p["path"]; pathExists {
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
@@ -231,23 +230,20 @@ func (m *RoleMsg) ToText(i int) string {
}
contentStr = strings.Join(textParts, " ") + " "
}
// check if already has role annotation (/completion makes them)
// in that case remove it, and then add to icon
// since icon and content are separated by \n
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
// if !strings.HasPrefix(contentStr, m.Role+":") {
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
// }
// Build final message with image indicators before text
var finalContent strings.Builder
if len(imageIndicators) > 0 {
// Add each image indicator on its own line
for _, indicator := range imageIndicators {
finalContent.WriteString(indicator)
finalContent.WriteString("\n")
}
}
finalContent.WriteString(contentStr)
if m.Stats != nil {
finalContent.WriteString(fmt.Sprintf("\n[gray::i][%d tok, %.1fs, %.1f t/s][-:-:-]",
m.Stats.Tokens, m.Stats.Duration, m.Stats.TokensPerSec))
}
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, finalContent.String())
return strings.ReplaceAll(textMsg, "\n\n", "\n")
}
@@ -329,6 +325,7 @@ func (m *RoleMsg) Copy() RoleMsg {
ContentParts: m.ContentParts,
ToolCallID: m.ToolCallID,
KnownTo: m.KnownTo,
Stats: m.Stats,
hasContentParts: m.hasContentParts,
}
}
@@ -641,6 +638,12 @@ func (lcp *LCPModels) ListModels() []string {
return resp
}
type ResponseStats struct {
Tokens int
Duration float64
TokensPerSec float64
}
type ChatRoundReq struct {
UserMsg string
Role string

View File

@@ -32,10 +32,16 @@ type OpenRouterChatReq struct {
MinP float32 `json:"min_p"`
NPredict int32 `json:"max_tokens"`
Tools []Tool `json:"tools"`
Reasoning *ReasoningConfig `json:"reasoning,omitempty"`
}
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatReq {
return OpenRouterChatReq{
type ReasoningConfig struct {
Effort string `json:"effort,omitempty"` // xhigh, high, medium, low, minimal, none
Summary string `json:"summary,omitempty"` // auto, concise, detailed
}
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32, reasoningEffort string) OpenRouterChatReq {
req := OpenRouterChatReq{
Messages: cb.Messages,
Model: cb.Model,
Stream: cb.Stream,
@@ -43,6 +49,13 @@ func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatR
MinP: props["min_p"],
NPredict: int32(props["n_predict"]),
}
// Only include reasoning config if effort is specified and not "none"
if reasoningEffort != "" && reasoningEffort != "none" {
req.Reasoning = &ReasoningConfig{
Effort: reasoningEffort,
}
}
return req
}
type OpenRouterChatRespNonStream struct {
@@ -82,6 +95,7 @@ type OpenRouterChatResp struct {
Delta struct {
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning"`
ToolCalls []ToolDeltaResp `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`

140
popups.go
View File

@@ -17,10 +17,14 @@ func showModelSelectionPopup() {
} else if strings.Contains(api, "openrouter.ai") {
return ORFreeModels
}
// Assume local llama.cpp
updateModelLists()
// Assume local llama.cpp - fetch with load status
models, err := fetchLCPModelsWithLoadStatus()
if err != nil {
logger.Error("failed to fetch models with load status", "error", err)
return LocalModels
}
return models
}
// Get the current model list based on the API
modelList := getModelListForAPI(cfg.CurrentAPI)
// Check for empty options list
@@ -57,12 +61,11 @@ func showModelSelectionPopup() {
modelListWidget.SetCurrentItem(currentModelIndex)
}
modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// Update the model in both chatBody and config
chatBody.Model = mainText
modelName := strings.TrimPrefix(mainText, "(loaded) ")
chatBody.Model = modelName
cfg.CurrentModel = chatBody.Model
// Remove the popup page
pages.RemovePage("modelSelectionPopup")
// Update the status line to reflect the change
updateCachedModelColor()
updateStatusLine()
})
modelListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@@ -156,10 +159,9 @@ func showAPILinkSelectionPopup() {
chatBody.Model = newModelList[0]
cfg.CurrentModel = chatBody.Model
}
// Remove the popup page
pages.RemovePage("apiLinkSelectionPopup")
// Update the parser and status line to reflect the change
choseChunkParser()
updateCachedModelColor()
updateStatusLine()
})
apiListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@@ -330,7 +332,7 @@ func showBotRoleSelectionPopup() {
}
func showFileCompletionPopup(filter string) {
baseDir := cfg.CodingDir
baseDir := cfg.FilePickerDir
if baseDir == "" {
baseDir = "."
}
@@ -387,3 +389,123 @@ func showFileCompletionPopup(filter string) {
pages.AddPage("fileCompletionPopup", modal(widget, 80, 20), true, true)
app.SetFocus(widget)
}
func updateWidgetColors(theme *tview.Theme) {
bgColor := theme.PrimitiveBackgroundColor
fgColor := theme.PrimaryTextColor
borderColor := theme.BorderColor
titleColor := theme.TitleColor
textView.SetBackgroundColor(bgColor)
textView.SetTextColor(fgColor)
textView.SetBorderColor(borderColor)
textView.SetTitleColor(titleColor)
textArea.SetBackgroundColor(bgColor)
textArea.SetBorderColor(borderColor)
textArea.SetTitleColor(titleColor)
textArea.SetTextStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
textArea.SetPlaceholderStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
// Force textarea refresh by restoring text (SetTextStyle doesn't trigger redraw)
textArea.SetText(textArea.GetText(), true)
editArea.SetBackgroundColor(bgColor)
editArea.SetBorderColor(borderColor)
editArea.SetTitleColor(titleColor)
editArea.SetTextStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
editArea.SetPlaceholderStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
// Force textarea refresh by restoring text (SetTextStyle doesn't trigger redraw)
editArea.SetText(editArea.GetText(), true)
statusLineWidget.SetBackgroundColor(bgColor)
statusLineWidget.SetTextColor(fgColor)
statusLineWidget.SetBorderColor(borderColor)
statusLineWidget.SetTitleColor(titleColor)
helpView.SetBackgroundColor(bgColor)
helpView.SetTextColor(fgColor)
helpView.SetBorderColor(borderColor)
helpView.SetTitleColor(titleColor)
searchField.SetBackgroundColor(bgColor)
searchField.SetBorderColor(borderColor)
searchField.SetTitleColor(titleColor)
}
// showColorschemeSelectionPopup creates a modal popup to select a colorscheme
func showColorschemeSelectionPopup() {
// Get the list of available colorschemes
schemeNames := make([]string, 0, len(colorschemes))
for name := range colorschemes {
schemeNames = append(schemeNames, name)
}
slices.Sort(schemeNames)
// Check for empty options list
if len(schemeNames) == 0 {
logger.Warn("no colorschemes available for selection")
message := "No colorschemes available."
if err := notifyUser("Empty list", message); err != nil {
logger.Error("failed to send notification", "error", err)
}
return
}
// Create a list primitive
schemeListWidget := tview.NewList().ShowSecondaryText(false).
SetSelectedBackgroundColor(tcell.ColorGray)
schemeListWidget.SetTitle("Select Colorscheme").SetBorder(true)
currentScheme := "default"
for name := range colorschemes {
if tview.Styles == colorschemes[name] {
currentScheme = name
break
}
}
currentSchemeIndex := -1
for i, scheme := range schemeNames {
if scheme == currentScheme {
currentSchemeIndex = i
}
schemeListWidget.AddItem(scheme, "", 0, nil)
}
// Set the current selection if found
if currentSchemeIndex != -1 {
schemeListWidget.SetCurrentItem(currentSchemeIndex)
}
schemeListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// Update the colorscheme
if theme, ok := colorschemes[mainText]; ok {
tview.Styles = theme
go func() {
app.QueueUpdateDraw(func() {
updateWidgetColors(&theme)
})
}()
}
// Remove the popup page
pages.RemovePage("colorschemeSelectionPopup")
})
schemeListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
pages.RemovePage("colorschemeSelectionPopup")
return nil
}
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage("colorschemeSelectionPopup")
return nil
}
return event
})
modal := func(p tview.Primitive, width, height int) tview.Primitive {
return tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(p, height, 1, true).
AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false)
}
// Add modal page and make it visible
pages.AddPage("colorschemeSelectionPopup", modal(schemeListWidget, 40, len(schemeNames)+2), true, true)
app.SetFocus(schemeListWidget)
}

View File

@@ -149,6 +149,11 @@ func makePropsTable(props map[string]float32) *tview.Table {
addListPopupRow("Set log level", logLevels, GetLogLevel(), func(option string) {
setLogLevel(option)
})
// Add reasoning effort dropdown (for OpenRouter and supported APIs)
reasoningEfforts := []string{"", "none", "minimal", "low", "medium", "high", "xhigh"}
addListPopupRow("Reasoning effort (OR)", reasoningEfforts, cfg.ReasoningEffort, func(option string) {
cfg.ReasoningEffort = option
})
// Helper function to get model list for a given API
getModelListForAPI := func(api string) []string {
if strings.Contains(api, "api.deepseek.com/") {

View File

@@ -1,5 +1,5 @@
{
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to CodingDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to CodingDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check `README` or documentation files\n- Identify: frameworks, conventions, testing approach\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths are resolved against CodingDir (configurable via Alt+O). Use absolute paths (starting with `/`) to bypass CodingDir.\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then modify for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Tool Usage Guidelines\n\n**File Operations**:\n- `file_read`: Read before editing. Use for understanding code.\n- `file_write`: Overwrite file content completely.\n- `file_write_append`: Add to end of file.\n- `file_create`: Create new files with optional content.\n- `file_list`: List directory contents (defaults to CodingDir).\n- Paths are relative to CodingDir unless starting with `/`.\n\n**Command Execution (WHITELISTED ONLY)**:\n- Allowed: grep, sed, awk, find, cat, head, tail, sort, uniq, wc, ls, echo, cut, tr, cp, mv, rm, mkdir, rmdir, pwd, df, free, ps, top, du, whoami, date, uname\n- **Git reads allowed**: git status, git log, git diff, git show, git branch, git reflog, git rev-parse, git shortlog, git describe\n- **Git actions FORBIDDEN**: git add, git commit, git push, git checkout, git reset, git rm, etc.\n- Use for searching code, reading git context, running tests/lint\n\n**Todo Management**:\n- `todo_create`: Add new task\n- `todo_read`: View all todos or specific one by ID\n- `todo_update`: Update task or change status (pending/in_progress/completed)\n- `todo_delete`: Remove completed or cancelled tasks\n\n## Important Rules\n\n1. **NEVER commit or stage changes**: Only git reads are allowed.\n2. **Check for tests**: Always look for test files and run them when appropriate.\n3. **Reference code locations**: Use format `file_path:line_number`.\n4. **Security**: Never generate or guess URLs. Only use URLs from local files.\n5. **Refuse malicious code**: If code appears malicious, refuse to work on it.\n6. **Ask clarifications**: When intent is unclear, ask questions.\n7. **Path handling**: Relative paths resolve against CodingDir. Use `/absolute/path` to bypass.\n\n## Response Style\n- Be direct and concise\n- One word answers are best when appropriate\n- Avoid: \"The answer is...\", \"Here is...\"\n- Use markdown for formatting\n- No emojis unless user explicitly requests",
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check `README` or documentation files\n- Identify: frameworks, conventions, testing approach\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths are resolved against FilePickerDir (configurable via Alt+O). Use absolute paths (starting with `/`) to bypass FilePickerDir.\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then modify for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Tool Usage Guidelines\n\n**File Operations**:\n- `file_read`: Read before editing. Use for understanding code.\n- `file_write`: Overwrite file content completely.\n- `file_write_append`: Add to end of file.\n- `file_create`: Create new files with optional content.\n- `file_list`: List directory contents (defaults to FilePickerDir).\n- Paths are relative to FilePickerDir unless starting with `/`.\n\n**Command Execution (WHITELISTED ONLY)**:\n- Allowed: grep, sed, awk, find, cat, head, tail, sort, uniq, wc, ls, echo, cut, tr, cp, mv, rm, mkdir, rmdir, pwd, df, free, ps, top, du, whoami, date, uname\n- **Git reads allowed**: git status, git log, git diff, git show, git branch, git reflog, git rev-parse, git shortlog, git describe\n- **Git actions FORBIDDEN**: git add, git commit, git push, git checkout, git reset, git rm, etc.\n- Use for searching code, reading git context, running tests/lint\n\n**Todo Management**:\n- `todo_create`: Add new task\n- `todo_read`: View all todos or specific one by ID\n- `todo_update`: Update task or change status (pending/in_progress/completed)\n- `todo_delete`: Remove completed or cancelled tasks\n\n## Important Rules\n\n1. **NEVER commit or stage changes**: Only git reads are allowed.\n2. **Check for tests**: Always look for test files and run them when appropriate.\n3. **Reference code locations**: Use format `file_path:line_number`.\n4. **Security**: Never generate or guess URLs. Only use URLs from local files.\n5. **Refuse malicious code**: If code appears malicious, refuse to work on it.\n6. **Ask clarifications**: When intent is unclear, ask questions.\n7. **Path handling**: Relative paths resolve against FilePickerDir. Use `/absolute/path` to bypass.\n\n## Response Style\n- Be direct and concise\n- One word answers are best when appropriate\n- Avoid: \"The answer is...\", \"Here is...\"\n- Use markdown for formatting\n- No emojis unless user explicitly requests",
"role": "CodingAssistant",
"filepath": "sysprompts/coding_assistant.json",
"first_msg": "Hello! I'm your coding assistant. I can help you with software engineering tasks like writing code, debugging, refactoring, and exploring codebases. I work best when you give me specific tasks, and for complex work, I'll create a todo list to track my progress. What would you like to work on?"

View File

@@ -820,7 +820,9 @@ func makeFilePicker() *tview.Flex {
}
// Create UI elements
listView := tview.NewList()
listView.SetBorder(true).SetTitle("Files & Directories [c: set CodingDir]").SetTitleAlign(tview.AlignLeft)
listView.SetBorder(true).
SetTitle("Files & Directories [s: set FilePickerDir]. Current base dir: " + cfg.FilePickerDir).
SetTitleAlign(tview.AlignLeft)
// Status view for selected file information
statusView := tview.NewTextView()
statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft)
@@ -1032,8 +1034,8 @@ func makeFilePicker() *tview.Flex {
refreshList(currentDisplayDir, "")
return nil
}
if event.Rune() == 'c' {
// Set CodingDir to current directory
if event.Rune() == 's' {
// Set FilePickerDir to current directory
itemIndex := listView.GetCurrentItem()
if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
itemText, _ := listView.GetItemText(itemIndex)
@@ -1046,6 +1048,7 @@ func makeFilePicker() *tview.Flex {
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos]
}
// nolint: gocritic
if strings.HasPrefix(actualItemName, "../") {
targetDir = path.Dir(currentDisplayDir)
} else if strings.HasSuffix(actualItemName, "/") {
@@ -1055,11 +1058,11 @@ func makeFilePicker() *tview.Flex {
targetDir = currentDisplayDir
}
}
cfg.CodingDir = targetDir
if err := notifyUser("CodingDir", "Set to: "+targetDir); err != nil {
cfg.FilePickerDir = targetDir
if err := notifyUser("FilePickerDir", "Set to: "+targetDir); err != nil {
logger.Error("failed to notify user", "error", err)
}
pages.RemovePage(filePickerPage)
// pages.RemovePage(filePickerPage)
return nil
}
}

View File

@@ -577,7 +577,7 @@ func resolvePath(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(cfg.CodingDir, p)
return filepath.Join(cfg.FilePickerDir, p)
}
func readStringFromFile(filename string) (string, error) {

89
tui.go
View File

@@ -10,14 +10,11 @@ import (
"path"
"strconv"
"strings"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
var _ = sync.RWMutex{}
var (
app *tview.Application
pages *tview.Pages
@@ -96,6 +93,7 @@ var (
[yellow]Alt+8[white]: show char img or last picked img
[yellow]Alt+9[white]: warm up (load) selected llama.cpp model
[yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks)
[yellow]Alt+i[white]: show colorscheme selection popup
=== scrolling chat window (some keys similar to vim) ===
[yellow]arrows up/down and j/k[white]: scroll up and down
@@ -108,74 +106,22 @@ var (
[yellow]x[white]: to exit the table page
=== filepicker ===
[yellow]c[white]: (in file picker) set current dir as CodingDir
[yellow]s[white]: (in file picker) set current dir as FilePickerDir
[yellow]x[white]: to exit
=== shell mode ===
[yellow]@match->Tab[white]: file completion (type @ in input to get file suggestions)
[yellow]@match->Tab[white]: file completion with relative paths (recursive, depth 3, max 50 files)
=== status line ===
%s
Press <Enter> or 'x' to return
`
colorschemes = map[string]tview.Theme{
"default": tview.Theme{
PrimitiveBackgroundColor: tcell.ColorDefault,
ContrastBackgroundColor: tcell.ColorGray,
MoreContrastBackgroundColor: tcell.ColorSteelBlue,
BorderColor: tcell.ColorGray,
TitleColor: tcell.ColorRed,
GraphicsColor: tcell.ColorBlue,
PrimaryTextColor: tcell.ColorLightGray,
SecondaryTextColor: tcell.ColorYellow,
TertiaryTextColor: tcell.ColorOrange,
InverseTextColor: tcell.ColorPurple,
ContrastSecondaryTextColor: tcell.ColorLime,
},
"gruvbox": tview.Theme{
PrimitiveBackgroundColor: tcell.ColorBlack, // Matches #1e1e2e
ContrastBackgroundColor: tcell.ColorDarkGoldenrod, // Selected option: warm yellow (#b57614)
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark grayish-blue (#32302f)
BorderColor: tcell.ColorLightGray, // Light gray (#a89984)
TitleColor: tcell.ColorRed, // Red (#fb4934)
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#689d6a)
PrimaryTextColor: tcell.ColorLightGray, // Light gray (#d5c4a1)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#fabd2f)
TertiaryTextColor: tcell.ColorOrange, // Orange (#fe8019)
InverseTextColor: tcell.ColorWhite, // White (#f9f5d7) for selected text
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#b8bb26)
},
"solarized": tview.Theme{
PrimitiveBackgroundColor: tcell.NewHexColor(0x1e1e2e), // #1e1e2e for main dropdown box
ContrastBackgroundColor: tcell.ColorDarkCyan, // Selected option: cyan (#2aa198)
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark blue (#073642)
BorderColor: tcell.ColorLightBlue, // Light blue (#839496)
TitleColor: tcell.ColorRed, // Red (#dc322f)
GraphicsColor: tcell.ColorBlue, // Blue (#268bd2)
PrimaryTextColor: tcell.ColorWhite, // White (#fdf6e3)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#b58900)
TertiaryTextColor: tcell.ColorOrange, // Orange (#cb4b16)
InverseTextColor: tcell.ColorWhite, // White (#eee8d5) for selected text
ContrastSecondaryTextColor: tcell.ColorLightCyan, // Light cyan (#93a1a1)
},
"dracula": tview.Theme{
PrimitiveBackgroundColor: tcell.NewHexColor(0x1e1e2e), // #1e1e2e for main dropdown box
ContrastBackgroundColor: tcell.ColorDarkMagenta, // Selected option: magenta (#bd93f9)
MoreContrastBackgroundColor: tcell.ColorDarkGray, // Non-selected options: dark gray (#44475a)
BorderColor: tcell.ColorLightGray, // Light gray (#f8f8f2)
TitleColor: tcell.ColorRed, // Red (#ff5555)
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#8be9fd)
PrimaryTextColor: tcell.ColorWhite, // White (#f8f8f2)
SecondaryTextColor: tcell.ColorYellow, // Yellow (#f1fa8c)
TertiaryTextColor: tcell.ColorOrange, // Orange (#ffb86c)
InverseTextColor: tcell.ColorWhite, // White (#f8f8f2) for selected text
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#50fa7b)
},
}
)
func init() {
// Start background goroutine to update model color cache
startModelColorUpdater()
tview.Styles = colorschemes["default"]
app = tview.NewApplication()
pages = tview.NewPages()
@@ -286,6 +232,11 @@ func init() {
statusLineWidget = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter)
// // vertical text center alignment
// statusLineWidget.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) {
// y += h / 2
// return x, y, w, h
// })
// Initially set up flex without search bar
flex = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(textView, 0, 40, false).
@@ -482,6 +433,19 @@ func init() {
pages.RemovePage(helpPage)
return nil
}
// Allow scrolling keys to pass through to the TextView
switch event.Key() {
case tcell.KeyUp, tcell.KeyDown,
tcell.KeyPgUp, tcell.KeyPgDn,
tcell.KeyHome, tcell.KeyEnd:
return event
}
if event.Key() == tcell.KeyRune {
switch event.Rune() {
case 'j', 'k', 'g', 'G':
return event
}
}
return nil
})
//
@@ -560,6 +524,10 @@ func init() {
}
return nil
}
if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 {
showColorschemeSelectionPopup()
return nil
}
if event.Key() == tcell.KeyF1 {
// chatList, err := loadHistoryChats()
chatList, err := store.GetChatByChar(cfg.AssistantRole)
@@ -731,6 +699,8 @@ func init() {
}
if event.Key() == tcell.KeyF12 {
// help window cheatsheet
// Update help text with current status before showing
helpView.SetText(fmt.Sprintf(helpText, makeStatusLine()))
pages.AddPage(helpPage, helpView, true, true)
return nil
}
@@ -865,6 +835,7 @@ func init() {
lastMsg := chatBody.Messages[len(chatBody.Messages)-1]
cleanedText := models.CleanText(lastMsg.Content)
if cleanedText != "" {
// nolint: errcheck
go orator.Speak(cleanedText)
}
}