6 Commits

Author SHA1 Message Date
Grail Finder
c65c11bcfb Fix: shellmode tab completion 2026-03-05 11:36:35 +03:00
Grail Finder
04f1fd464b Chore: remove cluedo sysprompt 2026-03-05 11:17:01 +03:00
Grail Finder
6e9c453ee0 Enha: explicit app.Draw per textView update for smooth streaming 2026-03-05 10:35:17 +03:00
Grail Finder
645b7351a8 Fix: add different kind of notifiction for fullscreen mode 2026-03-05 09:09:13 +03:00
Grail Finder
57088565bd Fix (notification): being closed by prev notification early 2026-03-05 08:51:04 +03:00
Grail Finder
4b6769e531 Fix (notification): non-blocking way to notify 2026-03-05 08:43:50 +03:00
6 changed files with 135 additions and 52 deletions

View File

@@ -38,3 +38,8 @@ func RegisterA(toolNames []string, a AgenterA) {
func Get(toolName string) AgenterB {
return RegistryB[toolName]
}
// Register is a convenience wrapper for RegisterB.
func Register(toolName string, a AgenterB) {
RegisterB(toolName, a)
}

View File

@@ -32,10 +32,10 @@ func detectAPI(api string) (isCompletion, isChat, isDeepSeek, isOpenRouter bool)
type AgentClient struct {
cfg *config.Config
getToken func() string
log *slog.Logger
log slog.Logger
}
func NewAgentClient(cfg *config.Config, log *slog.Logger, gt func() string) *AgentClient {
func NewAgentClient(cfg *config.Config, log slog.Logger, gt func() string) *AgentClient {
return &AgentClient{
cfg: cfg,
getToken: gt,
@@ -44,7 +44,7 @@ func NewAgentClient(cfg *config.Config, log *slog.Logger, gt func() string) *Age
}
func (ag *AgentClient) Log() *slog.Logger {
return ag.log
return &ag.log
}
func (ag *AgentClient) FormMsg(sysprompt, msg string) (io.Reader, error) {
@@ -63,9 +63,11 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
{Role: "system", Content: sysprompt},
{Role: "user", Content: msg},
}
// Determine API type
isCompletion, isChat, isDeepSeek, isOpenRouter := detectAPI(api)
ag.log.Debug("agent building request", "api", api, "isCompletion", isCompletion, "isChat", isChat, "isDeepSeek", isDeepSeek, "isOpenRouter", isOpenRouter)
// Build prompt for completion endpoints
if isCompletion {
var sb strings.Builder
@@ -74,6 +76,7 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
sb.WriteString("\n")
}
prompt := strings.TrimSpace(sb.String())
switch {
case isDeepSeek:
// DeepSeek completion
@@ -92,6 +95,7 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
return json.Marshal(req)
}
}
// Chat completions endpoints
if isChat || !isCompletion {
chatBody := &models.ChatBody{
@@ -99,6 +103,7 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
Stream: false, // Agents don't need streaming
Messages: messages,
}
switch {
case isDeepSeek:
// DeepSeek chat
@@ -117,6 +122,7 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
return json.Marshal(req)
}
}
// Fallback (should not reach here)
ag.log.Warn("unknown API, using default chat completions format", "api", api)
chatBody := &models.ChatBody{
@@ -159,6 +165,7 @@ func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) {
ag.log.Error("agent LLM request failed", "status", resp.StatusCode, "response", string(responseBytes[:min(len(responseBytes), 1000)]))
return responseBytes, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(responseBytes[:min(len(responseBytes), 200)]))
}
// Parse response and extract text content
text, err := extractTextFromResponse(responseBytes)
if err != nil {
@@ -172,16 +179,17 @@ func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) {
// extractTextFromResponse parses common LLM response formats and extracts the text content.
func extractTextFromResponse(data []byte) (string, error) {
// Try to parse as generic JSON first
var genericResp map[string]any
var genericResp map[string]interface{}
if err := json.Unmarshal(data, &genericResp); err != nil {
// Not JSON, return as string
return string(data), nil
}
// Check for OpenAI chat completion format
if choices, ok := genericResp["choices"].([]any); ok && len(choices) > 0 {
if firstChoice, ok := choices[0].(map[string]any); ok {
if choices, ok := genericResp["choices"].([]interface{}); ok && len(choices) > 0 {
if firstChoice, ok := choices[0].(map[string]interface{}); ok {
// Chat completion: choices[0].message.content
if message, ok := firstChoice["message"].(map[string]any); ok {
if message, ok := firstChoice["message"].(map[string]interface{}); ok {
if content, ok := message["content"].(string); ok {
return content, nil
}
@@ -191,17 +199,19 @@ func extractTextFromResponse(data []byte) (string, error) {
return text, nil
}
// Delta format for streaming (should not happen with stream: false)
if delta, ok := firstChoice["delta"].(map[string]any); ok {
if delta, ok := firstChoice["delta"].(map[string]interface{}); ok {
if content, ok := delta["content"].(string); ok {
return content, nil
}
}
}
}
// Check for llama.cpp completion format
if content, ok := genericResp["content"].(string); ok {
return content, nil
}
// Unknown format, return pretty-printed JSON
prettyJSON, err := json.MarshalIndent(genericResp, "", " ")
if err != nil {
@@ -209,3 +219,10 @@ func extractTextFromResponse(data []byte) (string, error) {
}
return string(prettyJSON), nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -521,7 +521,7 @@ func updateFlexLayout() {
if shellMode {
flex.AddItem(shellInput, 0, 10, false)
} else {
flex.AddItem(textArea, 0, 10, false)
flex.AddItem(bottomFlex, 0, 10, true)
}
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)

View File

@@ -1,7 +0,0 @@
{
"sys_prompt": "A game of cluedo. Players are {{user}}, {{char}}, {{char2}};\n\nrooms: hall, lounge, dinning room kitchen, ballroom, conservatory, billiard room, library, study;\nweapons: candlestick, dagger, lead pipe, revolver, rope, spanner;\npeople: miss Scarlett, colonel Mustard, mrs. White, reverend Green, mrs. Peacock, professor Plum;\n\nA murder happened in a mansion with 9 rooms. Victim is dr. Black.\nPlayers goal is to find out who commited a murder, in what room and with what weapon.\nWeapons, people and rooms not involved in murder are distributed between players (as cards) by tool agent.\nThe objective of the game is to deduce the details of the murder. There are six characters, six murder weapons, and nine rooms, leaving the players with 324 possibilities. As soon as a player enters a room, they may make a suggestion as to the details, naming a suspect, the room they are in, and the weapon. For example: \"I suspect Professor Plum, in the Dining Room, with the candlestick\".\nOnce a player makes a suggestion, the others are called upon to disprove it.\nBefore the player's move, tool agent will remind that players their cards. There are two types of moves: making a suggestion (suggestion_move) and disproving other player suggestion (evidence_move);\nIn this version player wins when the correct details are named in the suggestion_move.\n\n<example_game>\n{{user}}:\nlet's start a game of cluedo!\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; suggestion_move;\n{{char}}:\n(putting miss Scarlet into the Hall with the Revolver) \"I suspect miss Scarlett, in the Hall, with the revolver.\"\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; evidence_move;\n{{char2}}:\n\"No objections.\" (no cards matching the suspicion of {{char}})\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n\"I object. Miss Scarlett is innocent.\" (shows card with 'Miss Scarlett')\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; suggestion_move;\n{{char2}}:\n*So it was not Miss Scarlett, good to know.*\n(moves Mrs. White to the Billiard Room) \"It might have been Mrs. White, in the Billiard Room, with the Revolver.\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n(no matching cards for the assumption of {{char2}}) \"Sounds possible to me.\"\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; evidence_move;\n{{char}}:\n(shows Mrs. White card) \"No. Was not Mrs. White\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; suggestion_move;\n{{user}}:\n*So not Mrs. White...* (moves Reverend Green into the Billiard Room) \"I suspect Reverend Green, in the Billiard Room, with the Revolver.\"\ntool: Correct. It was Reverend Green in the Billiard Room, with the revolver. {{user}} wins.\n</example_game>",
"role": "CluedoPlayer",
"role2": "CluedoEnjoyer",
"filepath": "sysprompts/cluedo.json",
"first_msg": "Hey guys! Want to play cluedo?"
}

View File

@@ -278,13 +278,25 @@ func updateToolCapabilities() {
// getWebAgentClient returns a singleton AgentClient for web agents.
func getWebAgentClient() *agent.AgentClient {
webAgentClientOnce.Do(func() {
if cfg == nil {
if logger != nil {
logger.Warn("web agent client unavailable: config not initialized")
}
return
}
if logger == nil {
if logger != nil {
logger.Warn("web agent client unavailable: logger not initialized")
}
return
}
getToken := func() string {
if chunkParser == nil {
return ""
}
return chunkParser.GetToken()
}
webAgentClient = agent.NewAgentClient(cfg, logger, getToken)
webAgentClient = agent.NewAgentClient(cfg, *logger, getToken)
})
return webAgentClient
}
@@ -294,13 +306,13 @@ func registerWebAgents() {
webAgentsOnce.Do(func() {
client := getWebAgentClient()
// Register rag_search agent
agent.RegisterB("rag_search", agent.NewWebAgentB(client, ragSearchSysPrompt))
agent.Register("rag_search", agent.NewWebAgentB(client, ragSearchSysPrompt))
// Register websearch agent
agent.RegisterB("websearch", agent.NewWebAgentB(client, webSearchSysPrompt))
agent.Register("websearch", agent.NewWebAgentB(client, webSearchSysPrompt))
// Register read_url agent
agent.RegisterB("read_url", agent.NewWebAgentB(client, readURLSysPrompt))
agent.Register("read_url", agent.NewWebAgentB(client, readURLSysPrompt))
// Register summarize_chat agent
agent.RegisterB("summarize_chat", agent.NewWebAgentB(client, summarySysPrompt))
agent.Register("summarize_chat", agent.NewWebAgentB(client, summarySysPrompt))
})
}

72
tui.go
View File

@@ -29,6 +29,8 @@ var (
statusLineWidget *tview.TextView
helpView *tview.TextView
flex *tview.Flex
bottomFlex *tview.Flex
notificationWidget *tview.TextView
imgView *tview.Image
defaultImage = "sysprompts/llama.png"
indexPickWindow *tview.InputField
@@ -36,6 +38,7 @@ var (
roleEditWindow *tview.InputField
shellInput *tview.InputField
confirmModal *tview.Modal
toastTimer *time.Timer
confirmPageName = "confirm"
fullscreenMode bool
positionVisible bool = true
@@ -137,8 +140,8 @@ func setShellMode(enabled bool) {
}()
}
// showToast displays a temporary message in the topright corner.
// It autohides after 3 seconds and disappears when clicked.
// showToast displays a temporary notification in the bottom-right corner.
// It auto-hides after 3 seconds.
func showToast(title, message string) {
sanitize := func(s string, maxLen int) string {
sanitized := strings.Map(func(r rune) rune {
@@ -154,6 +157,11 @@ func showToast(title, message string) {
}
title = sanitize(title, 50)
message = sanitize(message, 197)
if toastTimer != nil {
toastTimer.Stop()
}
// show blocking notification to not mess up flex
if fullscreenMode {
notification := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
@@ -176,14 +184,44 @@ func showToast(title, message string) {
// Generate a unique page name (e.g., using timestamp) to allow multiple toasts.
pageName := fmt.Sprintf("toast-%d", time.Now().UnixNano())
pages.AddPage(pageName, background, true, true)
// Autodismiss after 3 seconds.
time.AfterFunc(3*time.Second, func() {
// Autodismiss after 2 seconds, since blocking is more annoying
time.AfterFunc(2*time.Second, func() {
app.QueueUpdateDraw(func() {
if pages.HasPage(pageName) {
pages.RemovePage(pageName)
}
})
})
return
}
notificationWidget.SetTitle(title)
notificationWidget.SetText(fmt.Sprintf("[yellow]%s[-]", message))
go func() {
app.QueueUpdateDraw(func() {
flex.RemoveItem(bottomFlex)
flex.RemoveItem(statusLineWidget)
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 40, 1, false)
flex.AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
})
}()
toastTimer = time.AfterFunc(3*time.Second, func() {
app.QueueUpdateDraw(func() {
flex.RemoveItem(bottomFlex)
flex.RemoveItem(statusLineWidget)
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 0, 0, false)
flex.AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
})
})
}
func init() {
@@ -235,7 +273,7 @@ func init() {
shellHistoryPos = -1
}
// Handle Tab key for @ file completion
if event.Key() == tcell.KeyTab {
if event.Key() == tcell.KeyTab && shellMode {
currentText := shellInput.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
@@ -286,12 +324,26 @@ func init() {
SetDynamicColors(true).
SetRegions(true).
SetChangedFunc(func() {
// INFO:
// https://github.com/rivo/tview/wiki/Concurrency#event-handlers
// although already called by default per tview specs
// calling it explicitly makes text streaming to look more smooth
app.Draw()
})
notificationWidget = tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetRegions(true).
SetChangedFunc(func() {
})
notificationWidget.SetBorder(true).SetTitle("notification")
bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
AddItem(textArea, 0, 1, true).
AddItem(notificationWidget, 0, 0, false)
//
flex = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(textView, 0, 40, false).
AddItem(textArea, 0, 10, true) // Restore original height
AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
@@ -360,10 +412,14 @@ func init() {
// y += h / 2
// return x, y, w, h
// })
notificationWidget.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).
AddItem(textArea, 0, 10, true) // Restore original height
AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
@@ -1095,7 +1151,7 @@ func init() {
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
return nil
}
if event.Key() == tcell.KeyTab {
if event.Key() == tcell.KeyTab && !shellMode {
currentF := app.GetFocus()
if currentF == textArea {
currentText := textArea.GetText()