diff --git a/shared/llm/types.go b/shared/llm/types.go index bf1eed60f..45ee4e6bb 100644 --- a/shared/llm/types.go +++ b/shared/llm/types.go @@ -161,6 +161,11 @@ const ( ToolGetEnvironment ToolID = "get_environment" ToolGlob ToolID = "glob" ToolSearchCode ToolID = "search_code" + ToolGetPage ToolID = "get_page" + ToolGetElements ToolID = "get_elements" + ToolHighlightElement ToolID = "highlight_element" + ToolFillElement ToolID = "fill_element" + ToolClearHighlighting ToolID = "clear_highlighting" // System tools ToolWorkComplete ToolID = "work_complete" // Unified completion tool (replaces code_complete and query_complete) @@ -215,6 +220,11 @@ var AvailableTools = []ToolID{ ToolListConnectedApps, ToolListConnectedAppActions, ToolExecuteConnectedAppAction, + ToolGetPage, + ToolGetElements, + ToolHighlightElement, + ToolFillElement, + ToolClearHighlighting, } var NoTools = []ToolID{} @@ -256,6 +266,11 @@ func init() { ToolGetEnvironment: ToolActorEnvironment, ToolGlob: ToolActorEnvironment, ToolSearchCode: ToolActorEnvironment, + ToolGetPage: ToolActorEnvironment, + ToolGetElements: ToolActorEnvironment, + ToolHighlightElement: ToolActorEnvironment, + ToolFillElement: ToolActorEnvironment, + ToolClearHighlighting: ToolActorEnvironment, // System tools ToolWorkComplete: ToolActorSystem, diff --git a/shared/tools/clear_highlighting.go b/shared/tools/clear_highlighting.go new file mode 100644 index 000000000..2e1fdce55 --- /dev/null +++ b/shared/tools/clear_highlighting.go @@ -0,0 +1,73 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +type clearHighlightInput struct { + // No parameters needed +} + +var clearHighlightSchema = GenerateSchema[clearHighlightInput]() + +type ClearHighlightTool struct { + BaseTool +} + +func NewClearHighlightTool(deps *ToolDependencies) *ClearHighlightTool { + return &ClearHighlightTool{ + BaseTool: NewBaseTool(deps), + } +} + +func (t *ClearHighlightTool) Name() string { + return "clear_highlighting" +} + +func (t *ClearHighlightTool) UseCase() string { + return `Use this tool to clear all active highlight animations on the TUI screen. +This removes any visual highlights that were previously applied with highlight_element.` +} + +func (t *ClearHighlightTool) Notes() string { + return ` +- No parameters required +- Clears highlights from all components at once +- Use this after highlighting elements to return the UI to normal state +- Safe to call even if no highlights are active` +} + +func (t *ClearHighlightTool) InputSchema() ToolInputSchema { + return clearHighlightSchema +} + +func (t *ClearHighlightTool) validate(input *clearHighlightInput) error { + // No validation needed - no parameters + return nil +} + +func (t *ClearHighlightTool) ValidateRawInput(input json.RawMessage) error { + _, err := ValidateAndParse(input, t.validate) + return err +} + +func (t *ClearHighlightTool) ExecuteRaw(ctx context.Context, input json.RawMessage) (bool, string, error) { + _, err := ValidateAndParse(input, t.validate) + if err != nil { + return false, "", err + } + + if t.deps.TUIState == nil { + return false, "", fmt.Errorf("clear_highlighting requires TUI state provider (tool cannot run in this context)") + } + + err = t.deps.TUIState.ClearHighlight(ctx) + if err != nil { + return false, "", fmt.Errorf("failed to clear highlights: %w", err) + } + + result := "Successfully cleared all highlights" + return false, result, nil +} diff --git a/shared/tools/executor.go b/shared/tools/executor.go index 9371a38d7..c17c0b24d 100644 --- a/shared/tools/executor.go +++ b/shared/tools/executor.go @@ -22,6 +22,7 @@ type Executor struct { threadPath string connectedAppClient ConnectedAppClient geminiClient *genai.Client + tuiState TUIStateProvider } // NewExecutor creates a new tool executor @@ -58,6 +59,11 @@ func (e *Executor) SetGeminiClient(geminiClient *genai.Client) { e.geminiClient = geminiClient } +// SetTUIState sets the TUI state provider for TUI awareness tools +func (e *Executor) SetTUIState(tuiState TUIStateProvider) { + e.tuiState = tuiState +} + // Execute executes a tool and returns the result // The tool is loaded, executed, and then freed from memory // Note: Actor validation should be done by the caller before calling Execute @@ -117,6 +123,7 @@ func (e *Executor) loadTool(toolName string) (Tool, error) { ThreadPath: e.threadPath, ConnectedAppClient: e.connectedAppClient, GeminiClient: e.geminiClient, + TUIState: e.tuiState, } return LoadTool(toolName, deps) } diff --git a/shared/tools/fill_element.go b/shared/tools/fill_element.go new file mode 100644 index 000000000..4b5d826b7 --- /dev/null +++ b/shared/tools/fill_element.go @@ -0,0 +1,128 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +type fillElementInput struct { + ElementName string `json:"element_name" jsonschema_description:"The name of the text input element to fill (use get_elements to see available elements)" jsonschema:"required"` + Text string `json:"text" jsonschema_description:"The text to insert into the input element" jsonschema:"required"` +} + +var fillElementSchema = GenerateSchema[fillElementInput]() + +type FillElementTool struct { + BaseTool +} + +func NewFillElementTool(deps *ToolDependencies) *FillElementTool { + return &FillElementTool{ + BaseTool: NewBaseTool(deps), + } +} + +func (t *FillElementTool) Name() string { + return "fill_element" +} + +func (t *FillElementTool) UseCase() string { + return `Use this tool to fill text into a text input element in the TUI. +This is useful for helping the user by pre-filling commands, queries, or other text they might want to use. +Use get_elements first to see the available text input elements.` +} + +func (t *FillElementTool) Notes() string { + return ` +- Requires element_name and text parameters +- The element_name must match a text_input element returned by get_elements +- Only works with text_input type elements - cannot fill other component types +- This helps the user by pre-filling text they might want to send or modify +- The user can still edit the filled text before sending +- Use this to suggest slash commands, queries, or other helpful text +- Returns an error if the element is not found or is not a text input` +} + +func (t *FillElementTool) InputSchema() ToolInputSchema { + return fillElementSchema +} + +func (t *FillElementTool) validate(input *fillElementInput) error { + if input.ElementName == "" { + return fmt.Errorf("element_name parameter is required") + } + if input.Text == "" { + return fmt.Errorf("text parameter is required") + } + return nil +} + +func (t *FillElementTool) ValidateRawInput(input json.RawMessage) error { + _, err := ValidateAndParse(input, t.validate) + return err +} + +func (t *FillElementTool) ExecuteRaw(ctx context.Context, input json.RawMessage) (bool, string, error) { + t.deps.Logger.Infow("fill_element: START", "input", string(input)) + + parsed, err := ValidateAndParse(input, t.validate) + if err != nil { + t.deps.Logger.Errorw("fill_element: validation failed", "error", err) + return false, "", err + } + + t.deps.Logger.Infow("fill_element: parsed input", "elementName", parsed.ElementName, "text", parsed.Text) + + if t.deps.TUIState == nil { + t.deps.Logger.Errorw("fill_element: TUIState is nil") + return false, "", fmt.Errorf("fill_element requires TUI state provider (tool cannot run in this context)") + } + + t.deps.Logger.Infow("fill_element: getting elements for validation") + + // Get all elements to validate the element exists and is fillable + elements, err := t.deps.TUIState.GetElements(ctx) + if err != nil { + t.deps.Logger.Errorw("fill_element: failed to get elements", "error", err) + return false, "", fmt.Errorf("failed to get elements: %w", err) + } + + t.deps.Logger.Infow("fill_element: got elements", "count", len(elements)) + + // Find the element and check if it's a text input + var found bool + var elementType string + for _, elem := range elements { + if elem.Name == parsed.ElementName { + found = true + elementType = elem.Type + t.deps.Logger.Infow("fill_element: found element", "name", elem.Name, "type", elem.Type) + break + } + } + + if !found { + t.deps.Logger.Errorw("fill_element: element not found", "elementName", parsed.ElementName) + return false, "", fmt.Errorf("element '%s' not found. Use get_elements to see available elements", parsed.ElementName) + } + + // Only allow filling text_input elements + if elementType != "text_input" { + t.deps.Logger.Errorw("fill_element: element is not text_input", "elementName", parsed.ElementName, "actualType", elementType) + return false, "", fmt.Errorf("element '%s' is not a text input (type: %s). Only text_input elements can be filled with text", parsed.ElementName, elementType) + } + + t.deps.Logger.Infow("fill_element: calling TUIState.FillElement", "elementName", parsed.ElementName, "text", parsed.Text) + + err = t.deps.TUIState.FillElement(ctx, parsed.ElementName, parsed.Text) + if err != nil { + t.deps.Logger.Errorw("fill_element: FillElement failed", "error", err) + return false, "", fmt.Errorf("failed to fill element '%s': %w", parsed.ElementName, err) + } + + t.deps.Logger.Infow("fill_element: SUCCESS - FillElement completed") + + result := fmt.Sprintf("Successfully filled element '%s' with text", parsed.ElementName) + return false, result, nil +} diff --git a/shared/tools/get_elements.go b/shared/tools/get_elements.go new file mode 100644 index 000000000..2820c7b8e --- /dev/null +++ b/shared/tools/get_elements.go @@ -0,0 +1,122 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type getElementsInput struct { + // This tool takes no parameters +} + +var getElementsSchema = GenerateSchema[getElementsInput]() + +type GetElementsTool struct { + BaseTool +} + +func NewGetElementsTool(deps *ToolDependencies) *GetElementsTool { + return &GetElementsTool{ + BaseTool: NewBaseTool(deps), + } +} + +func (t *GetElementsTool) Name() string { + return "get_elements" +} + +func (t *GetElementsTool) UseCase() string { + return `Use this tool to get a list of all visible elements on the current TUI screen. +This includes UI components (buttons, inputs, panels, etc.) and available slash commands. +Each element comes with a description and location information.` +} + +func (t *GetElementsTool) Notes() string { + return ` +- This tool takes no parameters +- Returns a list of all visible elements including: + - UI components (buttons, text inputs, panels, etc.) + - Available slash commands +- Each element includes: + - Name: unique identifier for the element + - Type: "component", "text_input", or "slash_command" + - Description: what the element does + - Location: where the element appears on screen + - IsHighlighted: whether the element is currently highlighted (only for components) +- Use this to understand what actions are available to the user +- Use element names with the highlight_element tool to draw attention to specific UI elements +- Check IsHighlighted field to see which components are currently highlighted` +} + +func (t *GetElementsTool) InputSchema() ToolInputSchema { + return getElementsSchema +} + +func (t *GetElementsTool) ValidateRawInput(input json.RawMessage) error { + // No validation needed - tool takes no parameters + return nil +} + +func (t *GetElementsTool) ExecuteRaw(ctx context.Context, input json.RawMessage) (bool, string, error) { + if t.deps.TUIState == nil { + return false, "", fmt.Errorf("get_elements requires TUI state provider (tool cannot run in this context)") + } + + elements, err := t.deps.TUIState.GetElements(ctx) + if err != nil { + return false, "", fmt.Errorf("failed to get elements: %w", err) + } + + if len(elements) == 0 { + return false, "No elements found on current page.", nil + } + + // Format elements into a readable list + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d element(s) on current page:\n\n", len(elements))) + + // Group by type + components := []TUIElement{} + slashCommands := []TUIElement{} + + for _, elem := range elements { + if elem.Type == "slash_command" { + slashCommands = append(slashCommands, elem) + } else { + components = append(components, elem) + } + } + + // Display components first + if len(components) > 0 { + sb.WriteString("=== UI Components ===\n\n") + for _, elem := range components { + highlightStatus := "" + if elem.IsHighlighted { + highlightStatus = " [HIGHLIGHTED]" + } + sb.WriteString(fmt.Sprintf("• %s%s\n", elem.Name, highlightStatus)) + sb.WriteString(fmt.Sprintf(" Type: %s\n", elem.Type)) + sb.WriteString(fmt.Sprintf(" Description: %s\n", elem.Description)) + sb.WriteString(fmt.Sprintf(" Location: %s\n", elem.Location)) + sb.WriteString(fmt.Sprintf(" IsHighlighted: %v\n\n", elem.IsHighlighted)) + } + } + + // Display slash commands + if len(slashCommands) > 0 { + sb.WriteString("=== Available Slash Commands ===\n\n") + for _, elem := range slashCommands { + sb.WriteString(fmt.Sprintf("• %s\n", elem.Name)) + sb.WriteString(fmt.Sprintf(" Description: %s\n", elem.Description)) + if elem.Location != "" { + sb.WriteString(fmt.Sprintf(" Location: %s\n", elem.Location)) + } + sb.WriteString("\n") + } + } + + return false, sb.String(), nil +} diff --git a/shared/tools/get_page.go b/shared/tools/get_page.go new file mode 100644 index 000000000..68775f60b --- /dev/null +++ b/shared/tools/get_page.go @@ -0,0 +1,62 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +type getPageInput struct { + // This tool takes no parameters +} + +var getPageSchema = GenerateSchema[getPageInput]() + +type GetPageTool struct { + BaseTool +} + +func NewGetPageTool(deps *ToolDependencies) *GetPageTool { + return &GetPageTool{ + BaseTool: NewBaseTool(deps), + } +} + +func (t *GetPageTool) Name() string { + return "get_page" +} + +func (t *GetPageTool) UseCase() string { + return `Use this tool to get information about the current page/view in the TUI. +This returns the name and description of the current screen, helping you understand what the user is looking at.` +} + +func (t *GetPageTool) Notes() string { + return ` +- This tool takes no parameters +- Returns the name and description of the current TUI page/view +- Use this to understand the context of what the user is viewing before suggesting actions` +} + +func (t *GetPageTool) InputSchema() ToolInputSchema { + return getPageSchema +} + +func (t *GetPageTool) ValidateRawInput(input json.RawMessage) error { + // No validation needed - tool takes no parameters + return nil +} + +func (t *GetPageTool) ExecuteRaw(ctx context.Context, input json.RawMessage) (bool, string, error) { + if t.deps.TUIState == nil { + return false, "", fmt.Errorf("get_page requires TUI state provider (tool cannot run in this context)") + } + + name, description, err := t.deps.TUIState.GetPageInfo(ctx) + if err != nil { + return false, "", fmt.Errorf("failed to get page info: %w", err) + } + + result := fmt.Sprintf("Current Page: %s\n\nDescription: %s", name, description) + return false, result, nil +} diff --git a/shared/tools/highlight_element.go b/shared/tools/highlight_element.go new file mode 100644 index 000000000..1d9dba616 --- /dev/null +++ b/shared/tools/highlight_element.go @@ -0,0 +1,103 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +type highlightElementInput struct { + ElementName string `json:"element_name" jsonschema_description:"The name of the element to highlight (use get_elements to see available element names)" jsonschema:"required"` +} + +var highlightElementSchema = GenerateSchema[highlightElementInput]() + +type HighlightElementTool struct { + BaseTool +} + +func NewHighlightElementTool(deps *ToolDependencies) *HighlightElementTool { + return &HighlightElementTool{ + BaseTool: NewBaseTool(deps), + } +} + +func (t *HighlightElementTool) Name() string { + return "highlight_element" +} + +func (t *HighlightElementTool) UseCase() string { + return `Use this tool to visually highlight a specific element on the TUI screen. +This draws the user's attention to a particular UI component or feature. +Use get_elements first to see the available element names.` +} + +func (t *HighlightElementTool) Notes() string { + return ` +- Requires element_name parameter +- The element_name must match an element returned by get_elements +- Use this to guide the user's attention to specific UI elements when explaining features or suggesting actions +- The highlight effect is temporary and visual only +- Returns an error if the element name is not found` +} + +func (t *HighlightElementTool) InputSchema() ToolInputSchema { + return highlightElementSchema +} + +func (t *HighlightElementTool) validate(input *highlightElementInput) error { + if input.ElementName == "" { + return fmt.Errorf("element_name parameter is required") + } + return nil +} + +func (t *HighlightElementTool) ValidateRawInput(input json.RawMessage) error { + _, err := ValidateAndParse(input, t.validate) + return err +} + +func (t *HighlightElementTool) ExecuteRaw(ctx context.Context, input json.RawMessage) (bool, string, error) { + parsed, err := ValidateAndParse(input, t.validate) + if err != nil { + return false, "", err + } + + if t.deps.TUIState == nil { + return false, "", fmt.Errorf("highlight_element requires TUI state provider (tool cannot run in this context)") + } + + // Get all elements to validate the element exists and is highlightable + elements, err := t.deps.TUIState.GetElements(ctx) + if err != nil { + return false, "", fmt.Errorf("failed to get elements: %w", err) + } + + // Find the element and check if it's highlightable + var found bool + var elementType string + for _, elem := range elements { + if elem.Name == parsed.ElementName { + found = true + elementType = elem.Type + break + } + } + + if !found { + return false, "", fmt.Errorf("element '%s' not found. Use get_elements to see available elements", parsed.ElementName) + } + + // Only allow highlighting of visual components, not slash commands + if elementType == "slash_command" { + return false, "", fmt.Errorf("cannot highlight slash commands (they are typed commands, not visual elements). Slash commands like '%s' must be typed into the message input", parsed.ElementName) + } + + err = t.deps.TUIState.HighlightElement(ctx, parsed.ElementName) + if err != nil { + return false, "", fmt.Errorf("failed to highlight element '%s': %w", parsed.ElementName, err) + } + + result := fmt.Sprintf("Successfully highlighted element: %s", parsed.ElementName) + return false, result, nil +} diff --git a/shared/tools/local_executor.go b/shared/tools/local_executor.go index 1489ccf59..acde5e3db 100644 --- a/shared/tools/local_executor.go +++ b/shared/tools/local_executor.go @@ -60,6 +60,10 @@ func (e *LocalExecutor) SetBackend(backend Backend) { e.executor.backend = backend } +func (e *LocalExecutor) SetTUIState(tuiState TUIStateProvider) { + e.executor.SetTUIState(tuiState) +} + func (e *LocalExecutor) Execute(ctx context.Context, toolUse *llm.ToolUseContent) (*llm.ToolResultContent, error) { return e.executor.Execute(ctx, toolUse) } diff --git a/shared/tools/tools.go b/shared/tools/tools.go index 07fc1143c..de1d86588 100644 --- a/shared/tools/tools.go +++ b/shared/tools/tools.go @@ -67,6 +67,10 @@ type ToolDependencies struct { // Optional: Gemini client for web search // Used by: web_search GeminiClient *genai.Client + + // Optional: TUI state provider for TUI-awareness tools + // Used by: get_page, get_elements, highlight_element + TUIState TUIStateProvider } // Backend interface for tools that need to interact with the backend @@ -245,6 +249,16 @@ func LoadTool(toolName string, deps *ToolDependencies) (Tool, error) { return NewKillProcessTool(deps), nil case llm.ToolProcessOutput: return NewProcessOutputTool(deps), nil + case llm.ToolGetPage: + return NewGetPageTool(deps), nil + case llm.ToolGetElements: + return NewGetElementsTool(deps), nil + case llm.ToolHighlightElement: + return NewHighlightElementTool(deps), nil + case llm.ToolFillElement: + return NewFillElementTool(deps), nil + case llm.ToolClearHighlighting: + return NewClearHighlightTool(deps), nil // System tools case llm.ToolWorkComplete: diff --git a/shared/tools/tui_state_types.go b/shared/tools/tui_state_types.go new file mode 100644 index 000000000..c540832ff --- /dev/null +++ b/shared/tools/tui_state_types.go @@ -0,0 +1,34 @@ +package tools + +import "context" + +// TUIElement represents an element (component or command) in the TUI +type TUIElement struct { + Name string // Unique identifier for the element + Type string // "component", "text_input", or "slash_command" + Description string // What the element does + Location string // Where the element is on screen + IsHighlighted bool // Whether the element is currently highlighted (only for components) +} + +// TUIStateProvider provides access to the current TUI state +// Implemented by the TUI model to allow tools to query and interact with the interface +type TUIStateProvider interface { + // GetPageInfo returns the name and description of the current TUI view/page + GetPageInfo(ctx context.Context) (name string, description string, err error) + + // GetElements returns all visible elements (components and slash commands) + // on the current screen + GetElements(ctx context.Context) ([]TUIElement, error) + + // HighlightElement triggers a highlight effect on the specified element + // Returns error if element not found + HighlightElement(ctx context.Context, elementName string) error + + // ClearHighlight clears all active highlight effects + ClearHighlight(ctx context.Context) error + + // FillElement fills text into a text input element + // Returns error if element not found or is not a text input + FillElement(ctx context.Context, elementName string, text string) error +} diff --git a/tim-api/internal/services/llm_response/handlers.go b/tim-api/internal/services/llm_response/handlers.go index 7ba549abd..fad222594 100644 --- a/tim-api/internal/services/llm_response/handlers.go +++ b/tim-api/internal/services/llm_response/handlers.go @@ -286,6 +286,7 @@ type contentBlockState struct { toolName string providerToolUseID string signature string // Signature for thinking blocks (used to verify integrity) + lastSummary string // Last extracted summary from work_complete JSON (for streaming) } func (s *Service) handleMessageStart(ctx context.Context, queries *db.Queries, threadPath *resourcepath.ThreadPath, messageUID *uuid.UUID) error { @@ -395,7 +396,7 @@ func (s *Service) handleContentBlockStart( providerToolUseID: start.ToolCallId, } - // Send notification for content start (only for text/thinking) + // Send notification for content start (for text/thinking and work_complete) if contentType == db.LlmMessageContentTypeText || contentType == db.LlmMessageContentTypeThinking { // Get message to determine role message, err := queries.GetMessageByUID(ctx, messageUID) @@ -406,6 +407,20 @@ func (s *Service) handleContentBlockStart( mapper.MessageRoleToNotifierRole(message.Role), ) } + } else if contentType == db.LlmMessageContentTypeToolUse && start.ToolName == "work_complete" { + // Send content start for work_complete so TUI can prepare to stream the summary + message, err := queries.GetMessageByUID(ctx, messageUID) + if err == nil { + // Send as TEXT type since we'll stream the summary text + notifier.NotifyContentStart( + content.UID, + mapper.MessageContentTypeToNotifierType(db.LlmMessageContentTypeText), + mapper.MessageRoleToNotifierRole(message.Role), + ) + s.logger.Debugw("sent content_start for work_complete tool", + "content_uid", content.UID, + "message_uid", messageUID) + } } return nil @@ -443,8 +458,46 @@ func (s *Service) handleContentBlockDelta( "signature_length", len(delta.SignatureDelta)) } - // Send notification for content delta (only for text/thinking and only if there's text) - if delta.TextDelta != "" && (block.contentType == db.LlmMessageContentTypeText || block.contentType == db.LlmMessageContentTypeThinking) { + // Handle work_complete tool specially - stream the summary as it builds + if block.contentType == db.LlmMessageContentTypeToolUse && block.toolName == "work_complete" && delta.TextDelta != "" { + // Extract summary from partial JSON using string manipulation + // JSON format: {"summary": "text here..."} + // We can extract even from incomplete JSON by finding the summary field + summaryStartMarker := `{"summary": "` + + // Check if we have at least the field marker + if len(block.buffer) > len(summaryStartMarker) { + // Extract everything after {"summary": " + summaryText := block.buffer[len(summaryStartMarker):] + + // Remove trailing "} or " if present (indicates complete or nearly complete JSON) + if len(summaryText) >= 2 && summaryText[len(summaryText)-2:] == `"}` { + summaryText = summaryText[:len(summaryText)-2] + } else if len(summaryText) >= 1 && summaryText[len(summaryText)-1:] == `"` { + summaryText = summaryText[:len(summaryText)-1] + } + + // Check if we have new content + if len(summaryText) > len(block.lastSummary) { + // Extract only the new portion + newText := summaryText[len(block.lastSummary):] + + if newText != "" { + // Send the incremental summary text + notifier.NotifyContentDelta(block.uid, newText) + s.logger.Debugw("streaming work_complete summary delta", + "content_uid", block.uid, + "delta_length", len(newText), + "total_summary_length", len(summaryText), + "buffer_length", len(block.buffer)) + } + + block.lastSummary = summaryText + } + } + // Don't return - we still want to accumulate the buffer + } else if delta.TextDelta != "" && (block.contentType == db.LlmMessageContentTypeText || block.contentType == db.LlmMessageContentTypeThinking) { + // Send notification for text/thinking content delta notifier.NotifyContentDelta(block.uid, delta.TextDelta) } @@ -535,6 +588,12 @@ func (s *Service) handleContentBlockStop( notifier.NotifyContentStop(block.uid) case db.LlmMessageContentTypeToolUse: + // Send content_stop for work_complete (since we streamed it as text) + if block.toolName == "work_complete" { + notifier.NotifyContentStop(block.uid) + s.logger.Debugw("sent content_stop for work_complete tool", "content_uid", block.uid) + } + // Send tool_call notification AFTER transaction commits // This ensures the tool call data is visible in the database when the event is processed toolCallID := toolUseContent.ID diff --git a/tim-cli-v2/ARCHITECTURE_INDEX.md b/tim-cli-v2/ARCHITECTURE_INDEX.md new file mode 100644 index 000000000..cfc83e017 --- /dev/null +++ b/tim-cli-v2/ARCHITECTURE_INDEX.md @@ -0,0 +1,426 @@ +# Tim CLI v2 - Architecture Documentation Index + +Complete reference guide to the tim-cli-v2 TUI application structure and design. + +## Quick Navigation + +### For Different Learning Styles + +**Visual Learners:** +- See diagrams in COMPONENT_INTERACTIONS.md +- View the layout diagram in TUI_ARCHITECTURE.md +- Check rendering pipeline sections + +**Deep Divers:** +- Start with "Component Architecture" in TUI_ARCHITECTURE.md +- Follow with "Data Flow Examples" +- Reference COMPONENT_INTERACTIONS.md for protocols + +**Code-First Learners:** +- Look at files in order (see below) +- Use line numbers to navigate +- Reference architecture docs when confused + +**Seekers of Specific Topics:** +- Use Ctrl+F to find topics in the .md files +- All major concepts indexed here + +--- + +## Documentation Files + +### TUI_ARCHITECTURE.md +**Main Reference** - Comprehensive guide (19KB, 450+ lines) + +Covers: +- Technology stack and framework choices +- Complete directory structure +- Component architecture (Model, Messages, Commands, etc.) +- Rendering architecture and view hierarchy +- State management patterns +- Event stream architecture +- All key features +- Integration points + +**Best for:** Understanding the full system, making architectural decisions, learning design patterns + +### COMPONENT_INTERACTIONS.md +**Message & Flow Reference** - Interaction patterns (11KB, 320+ lines) + +Covers: +- Component dependency graph +- Message flow paths (4 major patterns) +- State transitions and machines +- Protocol sequences +- Error handling patterns +- Goroutine management +- UI awareness integration + +**Best for:** Understanding how components talk, tracing message flows, debugging issues + +### README.md +**Getting Started** - Usage and quick reference + +Located in: `/Users/sambukowski/Desktop/code/tim/tim-cli-v2/README.md` + +--- + +## Source Files Quick Reference + +### Core TUI Files + +| File | Lines | Purpose | Read When | +|------|-------|---------|-----------| +| `styles.go` | 59 | All Lip Gloss styling definitions | Learning visual approach | +| `messages.go` | 85 | 13 custom Bubble Tea message types | Understanding state triggers | +| `command_picker.go` | 140 | Slash command autocomplete component | Studying reusable components | +| `commands.go` | 279 | 6 slash commands with handlers | Understanding extensibility | +| `tui_state.go` | 102 | TUIStateProvider for tools | Learning tool integration | +| `model.go` | 1516 | Main Bubble Tea model | Deep dive into logic | + +### Supporting Files + +| File | Lines | Purpose | +|------|-------|---------| +| `cmd/root.go` | 288 | CLI root command, interactive mode entry | +| `internal/client/client.go` | ~200 | Tim API client wrapper | +| `internal/auth/jwt.go` | ~50 | JWT token handling | + +--- + +## Architecture at a Glance + +### The Elm Pattern + +``` +Model -- Centralized state + ↓ +Update(msg tea.Msg) -- Pure state machine + ↓ +View() -- Pure rendering function + ↓ +Terminal Display -- User sees output +``` + +Every state change is a message, every message is handled by Update(). + +### The Main Model + +The `Model` struct (in model.go) contains: +- **Bubble Tea components**: viewport, textarea, spinner +- **Custom components**: CommandPicker +- **External services**: TimAPIClient, LocalExecutor +- **State fields** (~30): flags, caches, queues, context + +### The Message Types + +13 custom message types trigger state transitions: +- 3 for content streaming +- 3 for tool execution +- 4 for thread/stream management +- 3 for UI/commands + +### The Rendering + +View hierarchy with 7 sections: +1. Header (title + thread ID) +2. Viewport (scrollable messages) +3. Command Picker (optional) +4. Textarea (user input) +5. Help Text (dynamic) +6. Status Line (connection + state) +7. Sidebar (info panel) + +--- + +## Key Concepts + +### State Machines (4 major ones) + +1. **Message Streaming** + - User sends → waiting → stream arrives → streaming → complete → ready + +2. **Tool Execution** + - Tool call detected → execute → loading → result → display + +3. **Stream Reconnection** + - Disconnected → immediate retry → backoff + jitter → reconnect + +4. **Clarification Mode** + - Tool triggers → awaiting answer → submit → complete + +### Asynchronous Patterns + +All async operations: +```go +func (m *Model) operation() tea.Cmd { + return func() tea.Msg { + // Do work... + return resultMsg{...} + } +} +``` + +Examples: streamResponse, executeLocalTool, listenToStream, slash commands + +### Content Accumulation + +Messages stored as pre-styled strings: +- `messages: []string` - display strings +- `currentResponse: *strings.Builder` - streaming accumulator +- `displayedContentIDs: map[string]bool` - replay prevention + +### Connection Resilience + +Exponential backoff with jitter: +- First retry: immediate +- Subsequent: delay = baseDelay × 2^attempt +- Random jitter: ±20% +- Max delay: 30 seconds + +--- + +## Common Tasks & Where to Find Info + +### Understanding Message Flow +→ COMPONENT_INTERACTIONS.md "Message Flow Paths" + +### Debugging State Issues +→ COMPONENT_INTERACTIONS.md "State Transitions" + +### Adding Visual Features +→ TUI_ARCHITECTURE.md "Styling System" + +### Adding New Commands +→ TUI_ARCHITECTURE.md "Slash Commands" + commands.go + +### Understanding Tool Execution +→ COMPONENT_INTERACTIONS.md "Tool Execution → Result Display" + +### Learning Stream Handling +→ TUI_ARCHITECTURE.md "Event Stream Architecture" + +### Modifying Component Layout +→ TUI_ARCHITECTURE.md "Component Sizing" + model.go updateComponentSizes() + +### Adding TUI Awareness Features +→ TUI_ARCHITECTURE.md "UI Awareness for Tools" + tui_state.go + +--- + +## File Reading Order + +**For Complete Understanding:** + +1. **styles.go** (59 lines) - ~5 min + Understand the visual approach and styling strategy + +2. **messages.go** (85 lines) - ~5 min + Learn all message types that trigger state changes + +3. **command_picker.go** (140 lines) - ~10 min + See how a reusable component is built + +4. **commands.go** (279 lines) - ~15 min + Learn extensibility pattern for adding features + +5. **tui_state.go** (102 lines) - ~10 min + Understand tool integration points + +6. **model.go** (1516 lines) - ~60-90 min + Deep dive into main logic (start with struct definition, then pick key methods) + +7. **Documentation** + - TUI_ARCHITECTURE.md for context (~20 min) + - COMPONENT_INTERACTIONS.md for flows (~15 min) + +**Total Time:** ~2.5-3 hours for complete deep dive + +--- + +## Key Methods in model.go + +| Method | Line Range | Purpose | +|--------|-----------|---------| +| `NewModelWithThread()` | ~99-176 | Initialization | +| `Init()` | ~202-213 | Startup | +| `Update()` | ~223-685 | Main state machine | +| `View()` | ~688-800 | Rendering | +| `updateComponentSizes()` | ~1099-1129 | Dynamic sizing | +| `updateViewportContent()` | ~1137-1157 | Render with animation | +| `sendMessage()` | ~803-843 | User input handling | +| `streamResponse()` | ~899-940 | Start streaming | +| `listenToStream()` | ~943-1066 | Stream subscription | +| `executeLocalTool()` | ~1174-1307 | Tool execution | +| `formatToolResult()` | ~1346-1409 | Result formatting | + +--- + +## Debugging Support + +### Enable Debug Logging +```bash +tim-cli-v2 --debug +``` + +### Watch Logs Live +```bash +tail -f ~/.tim/logs/tim-cli-debug-*.log +``` + +### Log Output Includes +- All message types received/sent +- Stream connection events +- Component size calculations +- State transitions +- Tool execution flow +- Command picker interactions + +### Useful Log Patterns +- "Update()" - each frame +- "streamResponse" - starting message +- "listenToStream" - stream events +- "toolCall" - tool execution +- "fillElement" - tool UI interaction + +--- + +## Architecture Principles + +### 1. Event-Driven +Everything is a message. All state changes come from messages. + +### 2. Functional +Model methods return new Model (value semantics). +View is a pure function of Model state. + +### 3. Composable +Components combine (viewport + textarea + spinner). +Styles compose (colors + effects + layout). + +### 4. Async-First +All I/O returns commands that execute async. +Results come back as messages. + +### 5. Resilient +Stream auto-reconnects with exponential backoff. +Content deduplication prevents replays. +Tool results handled gracefully. + +### 6. Debuggable +Debug logging traces all operations. +Comprehensive error messages with suggestions. +Visual state indicators (spinner, status line). + +--- + +## Design Patterns Used + +| Pattern | Where | What | +|---------|-------|------| +| Elm Architecture | Bubble Tea core | Model + Update + View | +| Command Pattern | Async operations | Return Commands, get Messages | +| State Machine | Main update loop | Message type → state transition | +| Subscription | Stream handling | Goroutine → channel → Update | +| Retry with Backoff | Reconnection | Exponential delay with jitter | +| Deduplication | Content replay | Track displayed IDs | +| Component Composition | Layout | Vertical/horizontal stacking | +| Factory | Message creation | Message constructors | +| Observer | Style application | Lipgloss applies to strings | + +--- + +## Performance Considerations + +### Message Rendering +- Messages pre-styled → stored as strings +- No per-frame re-rendering +- Viewport handles scrolling + +### Memory +- `displayedContentIDs` map grows unbounded (consider limiting) +- Stream channel has capacity 10 (buffered) +- Goroutine cleanup via context cancellation + +### CPU +- Spinner animates on each frame (minimal overhead) +- View() recreates layout each frame (pure function) +- All I/O in async commands (non-blocking) + +--- + +## Future Enhancement Opportunities + +1. **Element Highlighting** - Visual effects in HighlightElement() +2. **Component Abstraction** - Reusable viewport/textarea wrappers +3. **Virtual Scrolling** - For very long message histories +4. **Customizable Keybindings** - User configuration +5. **Theme Support** - Multiple color schemes +6. **Message History** - Persistence and reload +7. **Performance** - Limit displayedContentIDs map size +8. **Multi-threaded Input** - Concurrent user input while streaming + +--- + +## Quick Reference + +### Terminal Dimensions +- Get from: WindowSizeMsg +- Store in: m.width, m.height +- Use for: Component sizing + +### Styling +- Define in: styles.go +- Apply with: `.Render(text)` +- Compose with: `.Margin().Border().Padding()` + +### State Flags +- waiting - user input disabled +- streamingResponse - content arriving +- awaitingClarification - clarify tool active +- commandPicker.visible - "/" typed + +### Message Types Count +- Content: 3 +- Tools: 3 +- Threads: 4 +- UI: 3 +- **Total: 13** + +### File Size Summary +- model.go: 70% of code +- commands.go: 13% +- Others: 17% + +--- + +## External Resources + +- Bubble Tea docs: https://github.com/charmbracelet/bubbletea +- Lip Gloss docs: https://github.com/charmbracelet/lipgloss +- Bubbles docs: https://github.com/charmbracelet/bubbles +- Elm Architecture: https://guide.elm-lang.org/ + +--- + +## Document Maintenance + +These docs were generated on: 2025-11-07 + +**When to Update:** +- New message types added +- New component added +- State machine changes +- New slash commands +- Architecture refactoring + +**How to Update:** +1. Update relevant .md file +2. Run grep to verify file references +3. Check line numbers in tables +4. Update this index + +--- + +Last reviewed: 2025-11-07 +Created by: Architecture Exploration Tool diff --git a/tim-cli-v2/COMPONENT_INTERACTIONS.md b/tim-cli-v2/COMPONENT_INTERACTIONS.md new file mode 100644 index 000000000..805b769e5 --- /dev/null +++ b/tim-cli-v2/COMPONENT_INTERACTIONS.md @@ -0,0 +1,492 @@ +# Tim CLI v2 - Component Interactions Reference + +Quick reference for how components interact and communicate. + +## Component Dependency Graph + +``` +Model (main TUI state) + ├─ Viewport (Bubbles) + │ └─ displays rendered messages + ├─ Textarea (Bubbles) + │ └─ captures user input + ├─ Spinner (Bubbles) + │ └─ animates in viewport + ├─ CommandPicker + │ └─ suggests slash commands + ├─ TimAPIClient + │ ├─ creates threads + │ ├─ sends messages + │ ├─ streams events + │ └─ submits tool results + └─ LocalExecutor (shared/tools) + ├─ executes environment tools + └─ implements TUIStateProvider +``` + +## Message Flow Paths + +### Path 1: User Input → Message Sent + +``` +KeyMsg (Enter) + ↓ +sendMessage() + ↓ +Add user message to display + ↓ +Thread creation or message creation + ↓ +threadCreatedMsg or nil + ↓ +listenToStream() (if new thread) +``` + +### Path 2: Stream Event → Display Update + +``` +Stream event (ContentDelta, ToolCall, etc.) + ↓ +Converted to Message type (contentDeltaMsg, toolCallMsg) + ↓ +Sent through subscription channel + ↓ +waitForMessage reads from channel + ↓ +Update() processes message + ↓ +Model state updated + ↓ +updateViewport() re-renders + ↓ +View() uses new state +``` + +### Path 3: Tool Execution → Result Display + +``` +Stream has toolCallMsg + ↓ +Environment tool detected + ↓ +executeLocalTool() goroutine spawned + ↓ +LocalExecutor.Execute(toolUse) + ↓ +Tool result obtained + ↓ +SubmitToolResult() sent to API + ↓ +toolResultMsg created + ↓ +Update() receives toolResultMsg + ↓ +Replace loading indicator in viewport +``` + +### Path 4: Stream Connection Loss → Reconnection + +``` +Stream ends (error or EOF) + ↓ +streamCompleteMsg or errMsg generated + ↓ +Update() receives message + ↓ +First retry? → Immediate reconnection +Nth retry? → tea.Tick() schedules delay + ↓ +calculateBackoffWithJitter() + ↓ +reconnectStreamMsg after delay + ↓ +listenToStream() opens new connection +``` + +## State Transitions + +### waiting Flag +``` +false (default) + ↓ [user sends message] + true (waiting for response) + ↓ [stream content arrives] + false (content is streaming in) + ↓ [thread goes IDLE] + false (ready for next input) +``` + +### streamingResponse Flag +``` +false (default) + ↓ [contentStartMsg for assistant content] + true (accumulating streaming content) + ↓ [contentDeltaMsg arrives] + true (appending to accumulated text) + ↓ [threadStateChangeMsg(IDLE)] + false (content complete) +``` + +### awaitingClarification Flag +``` +false (default) + ↓ [clarify tool call received] + true (showing question, waiting for answer) + ↓ [user types and presses Enter] + true (submitting answer) + ↓ [submitClarification() returns] + false (answer submitted, listening to stream) +``` + +## Component Sizing Logic + +When terminal size changes (WindowSizeMsg): + +``` +Get new terminal dimensions (m.width, m.height) + ↓ +Calculate header height (2 lines) +Calculate footer height (8 lines) +Check if command picker visible + ↓ +viewport.Width = m.width - 4 - sidebarWidth +viewport.Height = m.height - headerHeight - footerHeight - pickerHeight + ↓ +textarea.Width = viewport.Width + ↓ +viewport.GotoBottom() (keep scrolled to bottom) +``` + +## UI Element Rendering Order + +In `View()` method: + +1. **Header** - Title and thread ID + - 2 lines + +2. **Viewport** - All messages + - Height = total height - headers - footer + - Content = all messages joined with "\n\n" + - Spinner replaces {SPINNER} placeholders + - Scrolled to bottom + +3. **Command Picker** (if visible) + - Fixed 11 lines + - With padding above to maintain position + +4. **Textarea** - User input + - Bordered with rounded border + - Width matches viewport + +5. **Help Text** - Keyboard shortcuts + - Changes based on state (waiting, clarifying, etc.) + +6. **Status Line** - Connection and system state + - Stream: Connected/Reconnecting/Disconnected + - Status: Ready/Waiting/Streaming/Clarifying + +7. **Sidebar** - Information panel + - Right side, fixed 30 chars wide + - Thread ID, Persona, Message count, Token count + +## Key State Variables and Their Usage + +| Variable | Type | Used For | Updated When | +|----------|------|----------|--------------| +| `threadID` | string | API path building | Thread created | +| `messages` | []string | Viewport content | Message added/cleared | +| `waiting` | bool | Disable input, show spinner | User sends/receives content | +| `streamingResponse` | bool | Accumulate text, show spinner | Content starts/completes | +| `streamSub` | chan | Stream event subscription | Stream opens/closes | +| `currentResponse` | *Builder | Accumulate streaming text | Content arrives | +| `displayedContentIDs` | map | Prevent replay on reconnect | Content displayed | +| `executingToolsMap` | map | Find tool loading indicator | Tool executing/complete | +| `awaitingClarification` | bool | Clarification mode | Clarify tool triggered | +| `commandPicker.visible` | bool | Show/hide picker | "/" typed/removed | +| `retryCount` | int | Backoff calculation | Reconnection scheduled | + +## Rendering Patterns + +### Message Accumulation +```go +// When content starts +contentStartMsg → mark displayed, reset accumulator +↓ +// When deltas arrive +contentDeltaMsg → append to accumulator +→ build final styled string +→ replace last message in m.messages +→ updateViewport() +``` + +### Tool Execution Display +```go +// When tool call arrives +toolCallMsg → format as display string +→ append to m.messages +→ updateViewport() +↓ +// When tool starts executing +toolExecutingMsg → append loading message +→ track index in executingToolsMap +→ updateViewport() (spinner animates) +↓ +// When tool completes +toolResultMsg → get index from executingToolsMap +→ replace loading message with result +→ updateViewport() +``` + +### Viewport Animation +```go +// Every spinner tick update +spinnerCmd → updateViewportContent() called +↓ +Replace {SPINNER} placeholders +Replace waiting spinner at bottom +Reconstruct viewport content +Set new viewport content +``` + +## Protocol Flow: Message Send to Response Complete + +``` +1. User types "hello" and presses Enter + KeyMsg(Enter) → Update() + +2. sendMessage() executes + - Add "You: hello" to messages + - Reset textarea + - Set waiting=true + +3. streamResponse() creates thread/message + - API call to CreateThread or CreateUserMessage + - Returns threadCreatedMsg + +4. threadCreatedMsg → Update() + - Set m.threadID + - Call listenToStream() + +5. listenToStream() opens stream + - Returns streamReadyMsg with channel + - Spawns goroutine reading from stream + +6. streamReadyMsg → Update() + - Store channel in m.streamSub + - Call waitForMessage() + +7. Goroutine receives ContentStart + - Send contentStartMsg to channel + - Mark ID in displayedContentIDs + +8. waitForMessage() reads and sends contentStartMsg + - Update() processes it + - Set streamingResponse=true, waiting=false + - Call waitForMessage() again + +9. Goroutine receives ContentDeltas + - Send contentDeltaMsg to channel (multiple) + +10. Each contentDeltaMsg → Update() + - Append text to accumulator + - Update last message with styled text + - updateViewport() + - Call waitForMessage() + +11. Goroutine receives ThreadStateChange(IDLE) + - Send threadStateChangeMsg + +12. threadStateChangeMsg → Update() + - Set streamingResponse=false + - Response complete, ready for next input + - Call waitForMessage() + +13. User can type next message + - Enter key sends it + - Repeat from step 1 +``` + +## Error Handling Paths + +### Stream Error Detection +``` +Stream.Receive() returns error + ↓ +Goroutine sends errMsg with stream error + ↓ +Update() detects "stream error" in message + ↓ +Set streamingResponse=false +Set streamSub=nil + ↓ +Schedule reconnection with backoff +``` + +### Tool Execution Error +``` +LocalExecutor.Execute() returns error + ↓ +toolResultMsg created with isError=true + ↓ +Update() sees isError=true + ↓ +formatToolError() called instead of formatToolResult() + ↓ +Error message displayed with suggestions +``` + +### API Error +``` +API call fails (CreateThread, CreateUserMessage, etc.) + ↓ +errMsg created with error + ↓ +Update() receives errMsg + ↓ +Add error message to display + ↓ +Set waiting=false (enable input) + ↓ +updateViewport() +``` + +## Async Operation Patterns + +All async operations follow this pattern: + +```go +// Define command that returns function +func (m *Model) asyncOperation() tea.Cmd { + return func() tea.Msg { + // Do work here (blocking) + result := doWork() + + // Return message with result + return customMsg{data: result} + } +} + +// In Update(), call the command +return m, m.asyncOperation() + +// Message processed in next Update() iteration +case customMsg: + m.field = msg.data +``` + +Examples: +- `streamResponse()` - Creates thread/message +- `executeLocalTool()` - Runs tool locally +- `submitClarification()` - Submits tool result +- `listenToStream()` - Opens stream, spawns goroutine +- Each slash command handler + +## Goroutine Management + +### Stream Reading Goroutine +```go +listenToStream() spawns goroutine with streamCtx + ↓ +Goroutine reads stream.Receive() in loop + ↓ +Converts API events to Bubble Tea messages + ↓ +Sends messages to subscription channel + ↓ +On context cancellation or stream end: + - defer stream.Close() + - defer close(sub) + - defer streamCancel() + ↓ +Thread exits cleanly +``` + +### Tool Execution Goroutine +```go +executeLocalTool() command returns function + ↓ +Bubble Tea executes function (async) + ↓ +LocalExecutor.Execute(toolUse) runs + ↓ +On completion, returns toolResultMsg + ↓ +Function exits, goroutine cleaned up +``` + +## Command Picker Interaction + +### Activation +``` +KeyMsg received + ↓ +textarea updated + ↓ +Check if text starts with "/" + ↓ +Call commandPicker.Show(text) + ↓ +Filter commands matching prefix + ↓ +updateComponentSizes() (picker takes 11 lines) + ↓ +View() renders picker in next frame +``` + +### Navigation +``` +KeyUp → SelectPrev() → Update viewport +KeyDown → SelectNext() → Update viewport +Tab → Autocomplete selected, hide picker +Enter → Execute selected command, reset +Escape → Hide picker, updateComponentSizes() +``` + +## TUIStateProvider Integration + +When tools call these methods: + +### GetPageInfo() +```go +Returns: +- name: "Chat View" +- description: "Interactive chat interface..." +``` + +### GetElements() +```go +Returns list of TUIElements: +- message_input (textarea) +- chat_viewport (viewport) +- sidebar (sidebar) +- status_line (status) +- /help, /clear, /threads, etc. (slash commands) +``` + +### FillElement() +```go +Tool calls: FillElement("message_input", "text") + ↓ +Validation: Check element name is "message_input" + ↓ +Validation: Ensure text is not too long + ↓ +Return nil (validation only) + ↓ +Tool execution sends fillElementMsg in batch command + ↓ +fillElementMsg in Update(): + - textarea.SetValue(text) + - textarea.CursorEnd() + - textarea.Focus() +``` + +### HighlightElement() +```go +Tool calls: HighlightElement("chat_viewport") + ↓ +Currently stub - logs element name + ↓ +Future: Could trigger visual highlight effect +``` + diff --git a/tim-cli-v2/TUI_ARCHITECTURE.md b/tim-cli-v2/TUI_ARCHITECTURE.md new file mode 100644 index 000000000..9520d26ec --- /dev/null +++ b/tim-cli-v2/TUI_ARCHITECTURE.md @@ -0,0 +1,548 @@ +# Tim CLI v2 - TUI Architecture Summary + +## Overview +Tim CLI v2 is a modern terminal UI (TUI) chat application built with **Bubble Tea** and **Lip Gloss** that provides an interactive interface to the Tim API. It supports both single-query and interactive chat modes with real-time streaming, tool execution, and slash commands. + +## Technology Stack + +### TUI Framework +- **Bubble Tea** (v1.3.10) - Go TUI framework based on The Elm Architecture + - Provides event-driven, component-based UI model + - All UI updates happen through immutable messages in `Update()` + - View rendering is a pure function in `View()` + +- **Lip Gloss** (v1.1.0) - Styling and layout library + - Provides composable style objects (colors, borders, padding, margins) + - Handles terminal rendering with proper ANSI codes + - Used for all text styling and component layout + +- **Bubbles** (v0.21.0) - Reusable Bubble Tea components + - `textarea` - Multi-line text input with customizable key bindings + - `viewport` - Scrollable content area + - `spinner` - Animated loading indicator + +### API Communication +- **Connect-Go** (v1.19.1) - gRPC-compatible client +- **Protocol Buffers** - Type-safe API contracts +- Authentication via JWT tokens (stored in system keyring) + +## Directory Structure + +``` +tim-cli-v2/ +├── main.go # Entry point +├── cmd/ +│ ├── root.go # Root command with TUI mode and query mode +│ ├── auth.go # Authentication commands +│ ├── test_helpers.go # Testing utilities +│ └── test.go # Test commands +├── internal/ +│ ├── auth/ +│ │ └── jwt.go # JWT token handling +│ ├── client/ +│ │ ├── client.go # Tim API client wrapper +│ │ ├── query.go # Query execution +│ │ ├── stream.go # Stream event handling +│ │ └── connected_apps.go # Connected apps API +│ └── tui/ +│ ├── model.go # Main TUI model & Bubble Tea implementation (1500+ lines) +│ ├── messages.go # Custom Bubble Tea message types +│ ├── commands.go # Slash command definitions & handlers +│ ├── command_picker.go # Command autocomplete picker UI +│ ├── styles.go # Centralized Lip Gloss style definitions +│ └── tui_state.go # TUIStateProvider interface implementation +├── go.mod # Go dependencies +└── README.md # Documentation +``` + +## Component Architecture + +### 1. Main TUI Model (`model.go`) + +The `Model` struct is the central state container implementing the Bubble Tea pattern: + +```go +type Model struct { + // API Client + client *client.TimAPIClient + personaUID string + threadID string + + // UI Components (from Bubbles) + viewport viewport.Model // Scrollable message display + textarea textarea.Model // Message input area + spinner spinner.Model // Loading animation + + // Message State + messages []string // Rendered message strings + currentResponse *strings.Builder // Accumulates streaming content + displayedContentIDs map[string]bool // Track displayed content to avoid replay + + // Stream Management + streamSub chan tea.Msg // Subscription channel for stream events + streamCancel context.CancelFunc // Cancel individual stream context + + // Tool Execution + localExecutor *tools.LocalExecutor // Local tool executor + executingToolsMap map[string]int // Maps tool IDs to message indices + + // Clarification Mode (for clarify tool) + awaitingClarification bool + clarifyQuestion string + clarifyToolCallID string + clarifyToolCallPath string + + // Command Picker + commandPicker CommandPicker // Slash command autocomplete + + // Connection Management + retryCount int // Exponential backoff retry counter + baseDelay time.Duration // Base backoff delay (1s) + maxDelay time.Duration // Max backoff delay (30s) + + // Dimensions + width, height int + sidebarWidth int + + // State Flags + waiting bool // Waiting for LLM response + streamingResponse bool // Currently receiving content stream + waitingAfterToolCall bool // Waiting for LLM after tool result + + // Context + ctx context.Context + cancel context.CancelFunc + debugLogger *log.Logger +} +``` + +**Key Methods:** +- `Init() tea.Cmd` - Initializes the model on startup +- `Update(msg tea.Msg) (tea.Model, tea.Cmd)` - Core state update logic (700+ lines) +- `View() string` - Renders the TUI to a string +- `updateComponentSizes()` - Adjusts layout based on screen dimensions +- `updateViewportContent()` - Re-renders messages with animation +- `sendMessage() tea.Cmd` - Sends a user message +- `streamResponse(query string) tea.Cmd` - Creates thread and streams response +- `listenToStream(threadPath string) tea.Cmd` - Opens persistent event stream + +### 2. Message Types (`messages.go`) + +Custom Bubble Tea messages drive all state changes: + +```go +// Content streaming +contentStartMsg // Begin new content block (text, thinking, tool call) +contentDeltaMsg // Receive text delta from stream +threadStateChangeMsg // Thread LLM state changed (IDLE, RUNNING, etc.) + +// Tool execution +toolCallMsg // Tool call detected in stream +toolExecutingMsg // Local tool execution started +toolResultMsg // Tool execution completed + +// Thread management +threadCreatedMsg // New thread created +streamReadyMsg // Stream subscription ready (with cancel func) +streamCompleteMsg // Stream ended (gracefully) +reconnectStreamMsg // Reconnect after backoff delay +errMsg // Error occurred + +// Slash commands +slashCommandResultMsg // Command execution result + +// Element filling +fillElementMsg // Request to fill text into UI element (from tools) +``` + +### 3. Slash Commands (`commands.go`) + +```go +type SlashCommand struct { + Name string // Command name (without /) + Description string // Picker description + Usage string // Usage example + Aliases []string // Alternative names + Handler func(*Model, []string) tea.Cmd // Command handler function +} +``` + +**Available Commands:** +- `/help` (aliases: h, ?) - Show available commands +- `/model` (aliases: m) - Show current persona info +- `/clear` (aliases: cls) - Clear conversation view (local only) +- `/threads` (aliases: t, list) - List recent threads +- `/info` (aliases: i) - Show thread and persona info +- `/refresh` (aliases: r, reconnect) - Reconnect stream + +### 4. Command Picker (`command_picker.go`) + +Autocomplete UI component for slash commands: + +```go +type CommandPicker struct { + visible bool // Whether picker is visible + Commands []SlashCommand // Filtered commands + selectedIndex int // Current selection + FilterText string // Current filter (e.g., "/cl") +} +``` + +**Features:** +- Shows when user types `/` +- Filters commands by prefix in real-time +- Navigate with arrow keys, select with Enter +- Autocomplete with Tab key +- Cancel with Escape + +## Rendering Architecture + +### View Hierarchy + +The `View()` method builds a hierarchical layout: + +``` +┌─────────────────────────────────────────────────────┬──────┐ +│ Header (Title + Thread ID) │ │ +├─────────────────────────────────────────────────────┤ │ +│ │ │ +│ Viewport (Scrollable Messages) │ │ +│ │ │ +├─────────────────────────────────────────────────────┤ Side │ +│ Command Picker (if visible, max 11 lines) │ bar │ +│ │ │ +├─────────────────────────────────────────────────────┤ │ +│ Textarea (Message Input) │ │ +│ │ │ +├─────────────────────────────────────────────────────┤ │ +│ Help Text │ │ +├─────────────────────────────────────────────────────┤ │ +│ Status Line (Stream: Connected | Status: Streaming)│ │ +└─────────────────────────────────────────────────────┴──────┘ +``` + +### Styling System (`styles.go`) + +Centralized Lip Gloss style definitions: + +```go +titleStyle // Bold pink title +userMessageStyle // Cyan text, bold +assistantMessageStyle // Gold text +boldStyle // Orange, bold (for **markdown**) +thinkingStyle // Gray italic (for model thinking blocks) +systemMessageStyle // Dark gray italic +toolCallStyle // Purple italic +toolResultStyle // Light gray italic +inputStyle // Rounded border input box +helpStyle // Dark gray text +loadingStyle // Yellow text (spinner animation) +errorStyle // Bright red, bold +detailStyle // Light red (error details) +``` + +## State Management & Message Flow + +### Bubble Tea Update Loop + +``` +Update(msg tea.Msg) receives incoming message + ↓ +switch msg := msg.(type) handles specific types + ↓ +Updates Model fields (immutable pattern) + ↓ +Returns (updatedModel, command) + ↓ +Command executes asynchronously + ↓ +Returns new message back to Update loop +``` + +### Key State Machines + +#### 1. Message Streaming State +``` +User sends message + → waiting = true + → streamResponse command executes + → Creates thread (if needed) or sends message + → Opens stream subscription + → Starts listening to stream events + +Stream receives events + → contentStartMsg: Begin accumulating content + → contentDeltaMsg: Append to currentResponse + → Tool events: Handle tool calls + → ThreadStateChange: Thread goes IDLE + → waiting = false (enables user input again) +``` + +#### 2. Tool Execution State +``` +Stream has toolCallMsg + → If "clarify" tool: Enter awaitingClarification mode + → If environment tool: Execute locally + → toolExecutingMsg shows loading indicator + → executeLocalTool runs in goroutine + → toolResultMsg replaces loading indicator + → If other tool: Display tool call, wait for LLM response +``` + +#### 3. Stream Reconnection (Exponential Backoff) +``` +Stream connection drops + → streamCompleteMsg or errMsg + → First retry: Immediate reconnection + → Subsequent retries: exponential backoff with jitter + → delay = baseDelay * 2^attempt (capped at maxDelay) + → jitter: ±20% random variation + → reconnectStreamMsg schedules retry +``` + +### Component Sizing + +Dynamic layout adjustment based on terminal size: + +```go +Header height = 2 lines +Footer height = 8 lines (input + help + status + padding) +Command picker height = 0 or 11 lines (max) + +viewport.Width = terminalWidth - 4 (padding) - sidebarWidth (30) +viewport.Height = terminalHeight - headerHeight - footerHeight - pickerHeight + +textarea.Width = viewport.Width +``` + +## UI Awareness for Tools + +The Model implements `tools.TUIStateProvider` interface (`tui_state.go`): + +```go +GetPageInfo(ctx) → Returns page name and description +GetElements(ctx) → Returns list of visible UI elements: + - "message_input" (text_input) + - "chat_viewport" (component) + - "sidebar" (component) + - "status_line" (component) + - All slash commands as "slash_command" elements + +HighlightElement(ctx, elementName) → Triggers highlight (stub for future) +FillElement(ctx, elementName, text) → Fills text into "message_input" +``` + +This allows tools (especially the `fill_element` tool) to interact with the TUI. + +## Event Stream Architecture + +### Stream Lifecycle + +1. **Opening Stream** (`listenToStream`) + - Creates stream context with cancellation + - Spawns goroutine that reads from API stream + - Returns `streamReadyMsg` with channel and cancel func + - Goroutine sends messages to channel as events arrive + +2. **Event Processing** + - Goroutine converts API events to Bubble Tea messages + - Sends messages to channel (buffered, capacity 10) + - `waitForMessage` command reads from channel + - Each message triggers Update, which sends next `waitForMessage` + +3. **Stream Completion** + - Goroutine exits when stream ends + - Sends `streamCompleteMsg` or `errMsg` + - Model schedules reconnection with exponential backoff + +### Content Block Tracking + +Uses `displayedContentIDs` map to prevent content replay during reconnection: + +``` +First connection: + - Stream sends contentStartMsg for content ID "xyz" + - displayedContentIDs["xyz"] = true + - Content is displayed + +Stream disconnects, reconnects: + - Server may replay content blocks + - contentStartMsg for "xyz" arrives again + - Check: displayedContentIDs["xyz"] already true + - Skip this block to avoid duplicate display +``` + +## Key Features + +### 1. Real-time Streaming +- Persistent WebSocket-like connection maintained throughout session +- Content arrives as deltas for responsive UI +- Spinner animation shows activity + +### 2. Local Tool Execution +- Environment tools (file_read, exec_command, etc.) execute locally +- Results formatted for display +- Errors handled gracefully with suggestions + +### 3. Clarification Prompts +- Supports interactive tool that asks user questions +- Specialized `awaitingClarification` mode +- Answer submitted as tool result back to API + +### 4. Command Picker +- Autocomplete for slash commands +- Real-time filtering by prefix +- Tab for autocomplete, Enter to execute + +### 5. Resilient Connection Management +- Automatic reconnection on stream failure +- Exponential backoff to avoid overwhelming server +- Content deduplication prevents replay artifacts + +### 6. Message Formatting +- Markdown bold (`**text**`) rendered with color +- Thinking blocks separated from assistant responses +- Tool calls and results formatted distinctly +- JSON pretty-printed automatically + +### 7. Debug Logging +- Optional debug mode (`--debug` flag) +- Logs to `~/.tim/logs/tim-cli-debug-TIMESTAMP.log` +- Comprehensive trace of all operations + +## Data Flow Examples + +### Example 1: User Sends Message + +``` +User types "hello" and presses Enter + ↓ +KeyMsg(Enter) in Update() + ↓ +sendMessage() command + → Adds user message to display + → If no thread: CreateThread("Interactive Chat", personaUID, "hello") + → If has thread: CreateUserMessage(threadPath, "hello") + → Returns threadCreatedMsg or nil + ↓ +threadCreatedMsg → Update() → listenToStream() + ↓ +Stream opens, goroutine starts reading events + ↓ +contentStartMsg → marked displayed, accumulate content +contentDeltaMsg → append text, update viewport +contentDeltaMsg → ...more text... +threadStateChangeMsg(IDLE) → waiting = false, enable input +``` + +### Example 2: Tool Execution + +``` +Stream has toolCallMsg for "file_read" with path="/etc/hosts" + ↓ +Update() detects environment tool + ↓ +Display tool call in viewport +Send toolExecutingMsg +Execute executeLocalTool in goroutine + ↓ +Goroutine: + → LocalExecutor.Execute(toolUse) + → Get result + → SubmitToolResult(toolCallPath, result) + → If fill_element tool: extract parameters + → Send toolResultMsg + ↓ +Update() receives toolResultMsg + → Replace loading indicator with formatted result + → If fill_element: also send fillElementMsg + ↓ +fillElementMsg in Update() + → textarea.SetValue(text) + → textarea.Focus() +``` + +## Performance Considerations + +### Message Rendering +- Messages stored as rendered strings (pre-styled) +- Viewport handles scrolling/truncation +- Spinner animation updates entire viewport on each frame +- No per-frame re-rendering of all messages + +### Memory Management +- `displayedContentIDs` map grows unbounded during long sessions +- TODO: Consider limiting map size in extended sessions +- Stream subscription uses buffered channel (capacity 10) + +### Goroutine Model +- One goroutine per active stream +- Goroutine cancellation handled via context +- Clean resource cleanup on stream disconnect + +## Integration Points + +### With Tim API +- REST/gRPC endpoints via Connect-Go client +- Authentication via JWT in system keyring +- Streaming over HTTP/2 + +### With Local Tools +- `LocalExecutor` from shared/tools package +- Environment variable access, file I/O, command execution +- TUI awareness for element manipulation + +### With Authentication +- OAuth2 flow via `tim-cli-v2 auth login` +- Token stored in system keyring +- Cached user/org IDs for fallback + +## Future Enhancement Opportunities + +1. **Element Highlighting** - Implement visual highlight effects in `HighlightElement()` +2. **Multi-threaded Input** - Support concurrent user input while streaming +3. **Message History Persistence** - Save/load conversation history +4. **Component Abstraction** - Extract reusable viewport/textarea wrappers +5. **Improved Error Recovery** - More sophisticated retry strategies +6. **Performance Optimization** - Virtual scrolling for large message histories +7. **Customizable Keybindings** - User-configurable keyboard shortcuts +8. **Theme Support** - Multiple color schemes + +## Testing Strategy + +- Debug logging enables inspection of event flow +- Test helpers in `cmd/test_helpers.go` +- Manual testing with local API server +- Integration tests verify stream handling + +## Key Files Quick Reference + +| File | Lines | Purpose | +|------|-------|---------| +| `model.go` | 1516 | Main Bubble Tea model, all update logic, rendering | +| `messages.go` | 85 | Custom message type definitions | +| `commands.go` | 279 | Slash command definitions and handlers | +| `command_picker.go` | 140 | Command autocomplete UI component | +| `styles.go` | 59 | Centralized Lip Gloss style definitions | +| `tui_state.go` | 102 | TUIStateProvider interface implementation | +| `root.go` | 288 | CLI root command and interactive mode entry point | + +## Debugging Tips + +Enable debug logging: +```bash +tim-cli-v2 --debug +``` + +Watch logs in real-time: +```bash +tail -f ~/.tim/logs/tim-cli-debug-*.log +``` + +Debug log includes: +- All message types received and sent +- Stream connection/disconnection events +- Component size calculations +- State transitions (waiting, streaming, etc.) +- Tool execution flow +- Command picker interactions diff --git a/tim-cli-v2/cmd/root.go b/tim-cli-v2/cmd/root.go index 73981dd7a..ae374c354 100644 --- a/tim-cli-v2/cmd/root.go +++ b/tim-cli-v2/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -175,6 +176,11 @@ func run(cmd *cobra.Command, args []string) error { personasResp, err := timClient.ListPersonas(ctx) if err != nil { debugLog("Failed to list personas: %v", err) + // Check if this is an authentication error + errStr := err.Error() + if strings.Contains(errStr, "unauthenticated") || strings.Contains(errStr, "401") || strings.Contains(errStr, "session_not_found") { + return fmt.Errorf("authentication expired or invalid - please run 'tim-cli-v2 auth login' to log in again") + } return fmt.Errorf("failed to list personas: %w", err) } debugLog("Found %d personas", len(personasResp.Results)) diff --git a/tim-cli-v2/internal/tui/chat_viewport.go b/tim-cli-v2/internal/tui/chat_viewport.go new file mode 100644 index 000000000..fdd1662ef --- /dev/null +++ b/tim-cli-v2/internal/tui/chat_viewport.go @@ -0,0 +1,177 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ChatViewport wraps the Bubble Tea viewport with highlight support +type ChatViewport struct { + viewport viewport.Model + highlight HighlightState + messages []string + spinner *spinner.Model // Pointer to shared spinner for animations + executingTools map[string]int // Track executing tools for spinner replacement + showWaiting bool // Show "Waiting for response..." spinner +} + +// NewChatViewport creates a new chat viewport component +func NewChatViewport(width, height int, spinner *spinner.Model) *ChatViewport { + vp := viewport.New(width, height) + return &ChatViewport{ + viewport: vp, + highlight: HighlightState{}, + messages: []string{}, + spinner: spinner, + executingTools: make(map[string]int), + showWaiting: false, + } +} + +// Update implements the Component interface +func (c *ChatViewport) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + c.viewport, cmd = c.viewport.Update(msg) + return cmd +} + +// View implements the Component interface +func (c *ChatViewport) View() string { + // Build content from messages + content := strings.Join(c.messages, "\n\n") + + // Replace {SPINNER} placeholders with animated spinner for executing tools + if len(c.executingTools) > 0 && c.spinner != nil { + content = strings.ReplaceAll(content, "{SPINNER}", c.spinner.View()) + } + + // Add waiting spinner if needed + if c.showWaiting && c.spinner != nil { + content += "\n\n" + c.spinner.View() + " Waiting for response..." + } + + // Update viewport content + c.viewport.SetContent(content) + + // Only apply border when highlighted (use block border) + if c.highlight.IsHighlighted { + // Dark red/brown (88: #870000) to bright orange (214: #ffaf00) + borderColor := c.highlight.GetBorderColor("63", 135, 0, 0, 255, 175, 0) + style := lipgloss.NewStyle(). + Border(lipgloss.BlockBorder()). + BorderForeground(borderColor). + Width(c.viewport.Width). + Height(c.viewport.Height) + return style.Render(c.viewport.View()) + } + + return c.viewport.View() +} + +// Highlight implements the Component interface +func (c *ChatViewport) Highlight(enable bool) { + if enable { + c.highlight.StartHighlight() + } else { + c.highlight.StopHighlight() + } +} + +// IsHighlighted implements the Component interface +func (c *ChatViewport) IsHighlighted() bool { + return c.highlight.IsHighlighted +} + +// AdvanceHighlight implements the Component interface +func (c *ChatViewport) AdvanceHighlight(delta float64) { + c.highlight.Advance(delta) +} + +// SetSize implements the Sizable interface +func (c *ChatViewport) SetSize(width, height int) { + c.viewport.Width = width + c.viewport.Height = height +} + +// AddMessage adds a message to the viewport +func (c *ChatViewport) AddMessage(message string) { + c.messages = append(c.messages, message) + c.updateContent() +} + +// UpdateLastMessage updates the last message in the viewport +func (c *ChatViewport) UpdateLastMessage(message string) { + if len(c.messages) > 0 { + c.messages[len(c.messages)-1] = message + c.updateContent() + } +} + +// SetMessages replaces all messages +func (c *ChatViewport) SetMessages(messages []string) { + c.messages = messages + c.updateContent() +} + +// GetMessages returns all messages +func (c *ChatViewport) GetMessages() []string { + return c.messages +} + +// SetWaiting controls the waiting spinner display +func (c *ChatViewport) SetWaiting(waiting bool) { + c.showWaiting = waiting + c.updateContent() +} + +// TrackExecutingTool adds a tool execution to track (for spinner replacement) +func (c *ChatViewport) TrackExecutingTool(toolCallID string, messageIndex int) { + c.executingTools[toolCallID] = messageIndex +} + +// UntrackExecutingTool removes a tool execution from tracking +func (c *ChatViewport) UntrackExecutingTool(toolCallID string) { + delete(c.executingTools, toolCallID) +} + +// GetExecutingTools returns the map of executing tools +func (c *ChatViewport) GetExecutingTools() map[string]int { + return c.executingTools +} + +// GotoBottom scrolls the viewport to the bottom +func (c *ChatViewport) GotoBottom() { + c.viewport.GotoBottom() +} + +// Width returns the viewport width +func (c *ChatViewport) Width() int { + return c.viewport.Width +} + +// Height returns the viewport height +func (c *ChatViewport) Height() int { + return c.viewport.Height +} + +// updateContent updates the viewport content and scrolls to bottom +func (c *ChatViewport) updateContent() { + content := strings.Join(c.messages, "\n\n") + + // Replace {SPINNER} placeholders + if len(c.executingTools) > 0 && c.spinner != nil { + content = strings.ReplaceAll(content, "{SPINNER}", c.spinner.View()) + } + + // Add waiting spinner + if c.showWaiting && c.spinner != nil { + content += "\n\n" + c.spinner.View() + " Waiting for response..." + } + + c.viewport.SetContent(content) + c.viewport.GotoBottom() +} diff --git a/tim-cli-v2/internal/tui/command_picker.go b/tim-cli-v2/internal/tui/command_picker.go new file mode 100644 index 000000000..105cf8ab3 --- /dev/null +++ b/tim-cli-v2/internal/tui/command_picker.go @@ -0,0 +1,139 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// CommandPicker represents the state of the command picker popup +type CommandPicker struct { + visible bool + Commands []SlashCommand // Public so we can calculate height + selectedIndex int + FilterText string // Public so we can check if filter changed +} + +// NewCommandPicker creates a new command picker +func NewCommandPicker() CommandPicker { + return CommandPicker{ + visible: false, + Commands: []SlashCommand{}, + selectedIndex: 0, + FilterText: "", + } +} + +// Show displays the command picker with filtered commands +func (cp *CommandPicker) Show(filterText string) { + cp.visible = true + cp.FilterText = filterText + cp.Commands = getFilteredCommands(filterText) + cp.selectedIndex = 0 +} + +// Hide hides the command picker +func (cp *CommandPicker) Hide() { + cp.visible = false + cp.Commands = []SlashCommand{} + cp.selectedIndex = 0 + cp.FilterText = "" +} + +// SelectNext moves selection to the next command +func (cp *CommandPicker) SelectNext() { + if len(cp.Commands) == 0 { + return + } + cp.selectedIndex = (cp.selectedIndex + 1) % len(cp.Commands) +} + +// SelectPrev moves selection to the previous command +func (cp *CommandPicker) SelectPrev() { + if len(cp.Commands) == 0 { + return + } + cp.selectedIndex-- + if cp.selectedIndex < 0 { + cp.selectedIndex = len(cp.Commands) - 1 + } +} + +// GetSelected returns the currently selected command +func (cp *CommandPicker) GetSelected() *SlashCommand { + if !cp.visible || len(cp.Commands) == 0 { + return nil + } + if cp.selectedIndex >= 0 && cp.selectedIndex < len(cp.Commands) { + return &cp.Commands[cp.selectedIndex] + } + return nil +} + +// IsVisible returns whether the picker is visible +func (cp *CommandPicker) IsVisible() bool { + return cp.visible +} + +// Render renders the command picker as a string +func (cp *CommandPicker) Render(width int) string { + if !cp.visible || len(cp.Commands) == 0 { + return "" + } + + // Styles for the picker + pickerStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(0, 1). + Width(width - 4) + + selectedItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")). + Bold(true) + + itemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")) + + var content strings.Builder + content.WriteString(helpStyle.Render("Commands:")) + content.WriteString("\n") + + // Show up to 5 commands + maxVisible := 5 + if len(cp.Commands) > maxVisible { + content.WriteString(helpStyle.Render(" (showing first 5, keep typing to filter)")) + content.WriteString("\n") + } + + displayCount := len(cp.Commands) + if displayCount > maxVisible { + displayCount = maxVisible + } + + for i := 0; i < displayCount; i++ { + cmd := cp.Commands[i] + prefix := " " + if i == cp.selectedIndex { + prefix = "> " + } + + line := prefix + "/" + cmd.Name + if len(cmd.Aliases) > 0 { + line += " (" + strings.Join(cmd.Aliases, ",") + ")" + } + line += " - " + cmd.Description + + if i == cp.selectedIndex { + content.WriteString(selectedItemStyle.Render(line)) + } else { + content.WriteString(itemStyle.Render(line)) + } + content.WriteString("\n") + } + + content.WriteString("\n") + content.WriteString(helpStyle.Render(" ↑/↓: navigate • Tab: autocomplete • Enter: execute • Esc: cancel")) + + return pickerStyle.Render(content.String()) +} diff --git a/tim-cli-v2/internal/tui/commands.go b/tim-cli-v2/internal/tui/commands.go new file mode 100644 index 000000000..7187163df --- /dev/null +++ b/tim-cli-v2/internal/tui/commands.go @@ -0,0 +1,274 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/Greybox-Labs/tim/shared/apiclient" +) + +// SlashCommand represents a command that can be executed by the user +type SlashCommand struct { + Name string // Command name (without the /) + Description string // Short description for the picker + Usage string // Usage example + Aliases []string // Alternative names for the command + Handler func(*Model, []string) tea.Cmd +} + +// getSlashCommands returns available slash commands +// This avoids initialization cycle issues +func getSlashCommands() []SlashCommand { + return []SlashCommand{ + { + Name: "help", + Description: "Show available slash commands", + Usage: "/help", + Aliases: []string{"h", "?"}, + Handler: handleHelpCommand, + }, + { + Name: "model", + Description: "Show current model information", + Usage: "/model", + Aliases: []string{"m"}, + Handler: handleModelCommand, + }, + { + Name: "clear", + Description: "Clear the current conversation view", + Usage: "/clear", + Aliases: []string{"cls"}, + Handler: handleClearCommand, + }, + { + Name: "threads", + Description: "List recent threads", + Usage: "/threads", + Aliases: []string{"t", "list"}, + Handler: handleThreadsCommand, + }, + { + Name: "info", + Description: "Show thread and persona information", + Usage: "/info", + Aliases: []string{"i"}, + Handler: handleInfoCommand, + }, + { + Name: "refresh", + Description: "Reconnect the stream connection", + Usage: "/refresh", + Aliases: []string{"r", "reconnect"}, + Handler: handleRefreshCommand, + }, + } +} + +// parseSlashCommand checks if the input is a slash command and returns the command and args +func parseSlashCommand(input string) (command string, args []string, isCommand bool) { + input = strings.TrimSpace(input) + if !strings.HasPrefix(input, "/") { + return "", nil, false + } + + // Remove the leading slash + input = strings.TrimPrefix(input, "/") + if input == "" { + return "", nil, false + } + + // Split into command and arguments + parts := strings.Fields(input) + if len(parts) == 0 { + return "", nil, false + } + + command = strings.ToLower(parts[0]) + args = parts[1:] + return command, args, true +} + +// findCommand finds a slash command by name or alias +func findCommand(name string) *SlashCommand { + name = strings.ToLower(name) + commands := getSlashCommands() + for i := range commands { + cmd := &commands[i] + if cmd.Name == name { + return cmd + } + for _, alias := range cmd.Aliases { + if alias == name { + return cmd + } + } + } + return nil +} + +// getFilteredCommands returns commands that match the given prefix +func getFilteredCommands(prefix string) []SlashCommand { + commands := getSlashCommands() + if prefix == "" { + return commands + } + + prefix = strings.ToLower(strings.TrimPrefix(prefix, "/")) + var filtered []SlashCommand + + for _, cmd := range commands { + if strings.HasPrefix(cmd.Name, prefix) { + filtered = append(filtered, cmd) + continue + } + // Also check aliases + for _, alias := range cmd.Aliases { + if strings.HasPrefix(alias, prefix) { + filtered = append(filtered, cmd) + break + } + } + } + + return filtered +} + +// Command handlers + +func handleHelpCommand(m *Model, args []string) tea.Cmd { + var help strings.Builder + help.WriteString("Available commands:\n\n") + + // Get commands + commands := getSlashCommands() + for _, cmd := range commands { + help.WriteString(fmt.Sprintf(" /%s", cmd.Name)) + if len(cmd.Aliases) > 0 { + help.WriteString(fmt.Sprintf(" (aliases: %s)", strings.Join(cmd.Aliases, ", "))) + } + help.WriteString(fmt.Sprintf("\n %s\n\n", cmd.Description)) + } + + m.addMessage(help.String(), "system") + return nil +} + +func handleModelCommand(m *Model, args []string) tea.Cmd { + // Return a command that fetches the persona and displays model info + return func() tea.Msg { + ctx := context.Background() + personas, err := m.client.ListPersonas(ctx) + if err != nil { + return errMsg{err: fmt.Errorf("failed to list personas: %w", err)} + } + + // Find the current persona + var currentPersona string + for _, persona := range personas.Results { + if strings.Contains(persona.Path, m.personaUID) { + currentPersona = persona.DisplayName + // Note: We'd need to fetch the persona revision to get the model + // For now, just show the persona info + break + } + } + + if currentPersona == "" { + currentPersona = m.personaUID + } + + return slashCommandResultMsg{ + message: fmt.Sprintf("Current Persona: %s\nPersona UID: %s\n\nNote: Model information is stored in the persona revision.", currentPersona, m.personaUID), + } + } +} + +func handleClearCommand(m *Model, args []string) tea.Cmd { + m.chatViewport.SetMessages([]string{}) + m.displayedContentIDs = make(map[string]bool) + m.currentResponse.Reset() + + m.addMessage("Conversation cleared. Note: Server-side thread history is preserved.", "system") + return nil +} + +func handleThreadsCommand(m *Model, args []string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + resp, err := m.client.ListThreads(ctx, 10, "") + if err != nil { + return errMsg{err: fmt.Errorf("failed to list threads: %w", err)} + } + + var threads strings.Builder + threads.WriteString("Recent threads:\n\n") + + if len(resp.Results) == 0 { + threads.WriteString(" No threads found.\n") + } else { + for i, thread := range resp.Results { + // Extract thread ID from path + parts := strings.Split(thread.Path, "/") + threadID := parts[len(parts)-1] + + displayName := thread.DisplayName + if displayName == "" { + displayName = "(untitled)" + } + + current := "" + if threadID == m.threadID { + current = " [current]" + } + + threads.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, displayName, current)) + threads.WriteString(fmt.Sprintf(" ID: %s\n\n", threadID)) + } + } + + return slashCommandResultMsg{message: threads.String()} + } +} + +func handleInfoCommand(m *Model, args []string) tea.Cmd { + var info strings.Builder + info.WriteString("Thread Information:\n\n") + + if m.threadID == "" { + info.WriteString(" Thread: Not created yet\n") + } else { + info.WriteString(fmt.Sprintf(" Thread ID: %s\n", m.threadID)) + } + + info.WriteString(fmt.Sprintf(" Persona UID: %s\n", m.personaUID)) + messages := m.chatViewport.GetMessages() + info.WriteString(fmt.Sprintf(" Message count: %d\n", len(messages))) + info.WriteString(fmt.Sprintf(" Stream connected: %v\n", m.streamSub != nil)) + + m.addMessage(info.String(), "system") + return nil +} + +func handleRefreshCommand(m *Model, args []string) tea.Cmd { + if m.threadID == "" { + m.addMessage("No thread to refresh. Start a conversation first.", "system") + return nil + } + + // Don't close the channel - the goroutine owns it and will close it when done + // Just clear our reference to indicate we're not interested in the old stream + m.streamSub = nil + + // Reset retry count for fresh connection + m.retryCount = 0 + + // Status line will show "Reconnecting" status automatically + + // Reconnect to the stream + threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) + return m.listenToStream(threadPath) +} diff --git a/tim-cli-v2/internal/tui/component_base.go b/tim-cli-v2/internal/tui/component_base.go new file mode 100644 index 000000000..1fc4744a9 --- /dev/null +++ b/tim-cli-v2/internal/tui/component_base.go @@ -0,0 +1,115 @@ +package tui + +import ( + "fmt" + "math" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// HighlightState manages the animation state for component highlighting +type HighlightState struct { + IsHighlighted bool + Phase float64 // Animation phase 0.0 to 2.0 (one complete pulse cycle) +} + +// Advance moves the highlight animation forward by the given delta +func (h *HighlightState) Advance(delta float64) { + if h.IsHighlighted { + h.Phase += delta + // Keep phase in 0.0 to 2.0 range for continuous looping + if h.Phase >= 2.0 { + h.Phase -= 2.0 + } + } +} + +// GetIntensity returns the current intensity (0.0 to 1.0) based on sine wave +func (h *HighlightState) GetIntensity() float64 { + if !h.IsHighlighted { + return 0.0 + } + // Use sine wave for smooth pulsing: 0 -> 1 -> 0 over phase 0 -> 2 + return math.Abs(math.Sin(h.Phase * math.Pi)) +} + +// StartHighlight begins the highlight animation +func (h *HighlightState) StartHighlight() { + h.IsHighlighted = true + h.Phase = 0.0 +} + +// StopHighlight ends the highlight animation +func (h *HighlightState) StopHighlight() { + h.IsHighlighted = false + h.Phase = 0.0 +} + +// GetBorderColor returns an animated border color based on highlight state +// baseColor is the default color, darkColor and brightColor define the RGB range +func (h *HighlightState) GetBorderColor(baseColor string, darkR, darkG, darkB, brightR, brightG, brightB int) lipgloss.Color { + if !h.IsHighlighted { + return lipgloss.Color(baseColor) + } + + intensity := h.GetIntensity() + + // Interpolate RGB values smoothly + r := int(float64(darkR) + intensity*float64(brightR-darkR)) + g := int(float64(darkG) + intensity*float64(brightG-darkG)) + b := int(float64(darkB) + intensity*float64(brightB-darkB)) + + // Return as hex color + return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) +} + +// GetBackgroundColor returns an animated background color based on highlight state +// darkOrange and brightOrange define the color range +func (h *HighlightState) GetBackgroundColor(darkOrange, brightOrange int) lipgloss.Color { + if !h.IsHighlighted { + return lipgloss.Color("") // No background + } + + intensity := h.GetIntensity() + // Interpolate between dark and bright orange based on intensity + colorCode := int(float64(darkOrange) + intensity*float64(brightOrange-darkOrange)) + return lipgloss.Color(fmt.Sprintf("%d", colorCode)) +} + +// Component defines the interface that all TUI components must implement +// This follows the Bubble Tea Elm architecture pattern +type Component interface { + // Update handles Bubble Tea messages and returns commands + Update(msg tea.Msg) tea.Cmd + + // View renders the component to a string + View() string + + // Highlight controls the highlight state + Highlight(enable bool) + + // IsHighlighted returns whether the component is currently highlighted + IsHighlighted() bool + + // AdvanceHighlight advances the highlight animation by the given delta + AdvanceHighlight(delta float64) +} + +// Sizable is an optional interface for components that can be resized +type Sizable interface { + // SetSize updates the component's dimensions + SetSize(width, height int) +} + +// Focusable is an optional interface for components that can receive focus +type Focusable interface { + // Focus sets focus on the component + Focus() tea.Cmd + + // Blur removes focus from the component + Blur() + + // Focused returns whether the component has focus + Focused() bool +} diff --git a/tim-cli-v2/internal/tui/message_input.go b/tim-cli-v2/internal/tui/message_input.go new file mode 100644 index 000000000..082828b4c --- /dev/null +++ b/tim-cli-v2/internal/tui/message_input.go @@ -0,0 +1,133 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// MessageInput wraps the Bubble Tea textarea with highlight support +type MessageInput struct { + textarea textarea.Model + highlight HighlightState +} + +// NewMessageInput creates a new message input component +func NewMessageInput() *MessageInput { + ta := textarea.New() + ta.Placeholder = "Type your message..." + ta.Focus() + ta.CharLimit = 4096 + ta.SetWidth(80) + ta.SetHeight(3) + ta.ShowLineNumbers = false + + // Override key bindings so Enter doesn't insert newline + // Shift+Enter will insert newline, plain Enter will send message + ta.KeyMap.InsertNewline.SetKeys("shift+enter", "ctrl+j") + ta.KeyMap.InsertNewline.SetHelp("shift+enter", "new line") + + return &MessageInput{ + textarea: ta, + highlight: HighlightState{}, + } +} + +// Update implements the Component interface +func (m *MessageInput) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return cmd +} + +// View implements the Component interface +func (m *MessageInput) View() string { + // Apply border with highlight if enabled + // Dark red/brown (88: #870000) to bright orange (214: #ffaf00) + borderColor := m.highlight.GetBorderColor("208", 135, 0, 0, 255, 175, 0) + + // Use block border when highlighted, rounded border otherwise + borderStyle := lipgloss.RoundedBorder() + if m.highlight.IsHighlighted { + borderStyle = lipgloss.BlockBorder() + } + + style := lipgloss.NewStyle(). + BorderStyle(borderStyle). + BorderForeground(borderColor). + Padding(0, 1) + + return style.Render(m.textarea.View()) +} + +// Highlight implements the Component interface +func (m *MessageInput) Highlight(enable bool) { + if enable { + m.highlight.StartHighlight() + } else { + m.highlight.StopHighlight() + } +} + +// IsHighlighted implements the Component interface +func (m *MessageInput) IsHighlighted() bool { + return m.highlight.IsHighlighted +} + +// AdvanceHighlight implements the Component interface +func (m *MessageInput) AdvanceHighlight(delta float64) { + m.highlight.Advance(delta) +} + +// Focus implements the Focusable interface +func (m *MessageInput) Focus() tea.Cmd { + return m.textarea.Focus() +} + +// Blur implements the Focusable interface +func (m *MessageInput) Blur() { + m.textarea.Blur() +} + +// Focused implements the Focusable interface +func (m *MessageInput) Focused() bool { + return m.textarea.Focused() +} + +// SetSize implements the Sizable interface +func (m *MessageInput) SetSize(width, height int) { + m.textarea.SetWidth(width) + if height > 0 { + m.textarea.SetHeight(height) + } +} + +// Value returns the current text value +func (m *MessageInput) Value() string { + return m.textarea.Value() +} + +// SetValue sets the text value +func (m *MessageInput) SetValue(text string) { + m.textarea.SetValue(text) +} + +// Reset clears the input +func (m *MessageInput) Reset() { + m.textarea.Reset() +} + +// CursorEnd moves the cursor to the end +func (m *MessageInput) CursorEnd() { + m.textarea.CursorEnd() +} + +// Width returns the textarea width +func (m *MessageInput) Width() int { + return m.textarea.Width() +} + +// Height returns the textarea height +func (m *MessageInput) Height() int { + return m.textarea.Height() +} diff --git a/tim-cli-v2/internal/tui/messages.go b/tim-cli-v2/internal/tui/messages.go new file mode 100644 index 000000000..161debf45 --- /dev/null +++ b/tim-cli-v2/internal/tui/messages.go @@ -0,0 +1,96 @@ +package tui + +import ( + "context" + + threadv1alpha1 "github.com/Greybox-Labs/tim/tim-proto/gen/tim/api/thread/v1alpha1" + tea "github.com/charmbracelet/bubbletea" +) + +// Message types +type ( + // contentStartMsg is sent when a content block starts + contentStartMsg struct { + contentID string + role threadv1alpha1.LlmMessageRole + contentType threadv1alpha1.ContentType + } + + // contentDeltaMsg is sent when we receive a content delta from the stream + contentDeltaMsg struct { + contentID string + text string + } + + // toolCallMsg is sent when a tool call is made + toolCallMsg struct { + toolCallID string + toolName string + summary string // For work_complete tool + input map[string]interface{} // For other tools (like clarify) + } + + // toolResultMsg is sent when a tool result is ready to display + toolResultMsg struct { + toolName string + toolCallID string // Used to find and replace the loading indicator + result string + isError bool + } + + // toolExecutingMsg is sent when a tool starts executing + toolExecutingMsg struct { + toolName string + toolCallID string + } + + // threadCreatedMsg is sent when a new thread is created + threadCreatedMsg struct { + threadID string + } + + // streamReadyMsg is sent when the stream subscription is ready + streamReadyMsg struct { + sub chan tea.Msg + cancel context.CancelFunc + } + + // streamCompleteMsg is sent when the stream is complete + streamCompleteMsg struct{} + + // reconnectStreamMsg is sent after backoff delay to reconnect the stream + reconnectStreamMsg struct{} + + // threadStateChangeMsg is sent when thread state changes + threadStateChangeMsg struct { + state threadv1alpha1.ThreadLLMState + } + + // errMsg is sent when an error occurs + errMsg struct { + err error + } + + // slashCommandResultMsg is sent when a slash command completes + slashCommandResultMsg struct { + message string + } + + // fillElementMsg is sent when a text input element should be filled with text + fillElementMsg struct { + elementName string + text string + } + + // highlightTickMsg is sent periodically to advance highlight animations + highlightTickMsg struct{} + + // highlightElementMsg is sent when an element should be highlighted + highlightElementMsg struct { + elementName string + enable bool // true to highlight, false to unhighlight + } + + // clearHighlightMsg is sent when all highlights should be cleared + clearHighlightMsg struct{} +) diff --git a/tim-cli-v2/internal/tui/model.go b/tim-cli-v2/internal/tui/model.go index f623cb54c..a4a70b1ba 100644 --- a/tim-cli-v2/internal/tui/model.go +++ b/tim-cli-v2/internal/tui/model.go @@ -12,8 +12,8 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "google.golang.org/protobuf/types/known/structpb" @@ -32,129 +32,37 @@ var ( boldTextRegex = regexp.MustCompile(`\*\*([^*]+)\*\*`) ) -// Styles -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("212")). - MarginLeft(1) - - userMessageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("86")). - Bold(true) - - assistantMessageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("220")) // Gold - - boldStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("208")). // Deep orange - Bold(true) - - thinkingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")). // Brighter gray - Italic(true) - - systemMessageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Italic(true) - - toolCallStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("99")). // Purple - Italic(true) - - toolResultStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("246")). // Gray - Italic(true) - - inputStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("63")). - Padding(0, 1) - - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) -) - -// Message types -type ( - // contentStartMsg is sent when a content block starts - contentStartMsg struct { - contentID string - role threadv1alpha1.LlmMessageRole - contentType threadv1alpha1.ContentType - } - - // contentDeltaMsg is sent when we receive a content delta from the stream - contentDeltaMsg struct { - contentID string - text string - } - - // toolCallMsg is sent when a tool call is made - toolCallMsg struct { - toolCallID string - toolName string - summary string // For work_complete tool - input map[string]interface{} // For other tools (like clarify) - } - - // toolResultMsg is sent when a tool result is ready to display - toolResultMsg struct { - toolName string - toolCallID string // Used to find and replace the loading indicator - result string - isError bool - } - - // toolExecutingMsg is sent when a tool starts executing - toolExecutingMsg struct { - toolName string - toolCallID string - } - - // threadCreatedMsg is sent when a new thread is created - threadCreatedMsg struct { - threadID string - } - - // streamReadyMsg is sent when the stream subscription is ready - streamReadyMsg struct { - sub chan tea.Msg - } - - // streamCompleteMsg is sent when the stream is complete - streamCompleteMsg struct{} - - // reconnectStreamMsg is sent after backoff delay to reconnect the stream - reconnectStreamMsg struct{} - - // errMsg is sent when an error occurs - errMsg struct { - err error - } -) - // Model represents the chat TUI state type Model struct { client *client.TimAPIClient personaUID string threadID string - viewport viewport.Model - textarea textarea.Model - messages []string + // UI Components + chatViewport *ChatViewport + messageInput *MessageInput + sidebar *Sidebar + statusLine *StatusLine + spinner *spinner.Model width int height int - waiting bool - streamingResponse bool // True when receiving streamed response - currentResponse *strings.Builder // Accumulates streaming text (pointer to avoid copy issues) - currentContentRole threadv1alpha1.LlmMessageRole // Role of current content block - currentContentType threadv1alpha1.ContentType // Type of current content block - displayedContentIDs map[string]bool // Track which content IDs we've already displayed - streamSub chan tea.Msg // Subscription channel for streaming events - err error + // Sidebar configuration + sidebarWidth int + totalTokens int + + waiting bool + streamingResponse bool // True when receiving streamed response + waitingAfterToolCall bool // True when waiting for LLM response after tool call + currentResponse *strings.Builder // Accumulates streaming text (pointer to avoid copy issues) + currentContentRole threadv1alpha1.LlmMessageRole // Role of current content block + currentContentType threadv1alpha1.ContentType // Type of current content block + currentContentID string // ID of the content block currently streaming + displayedContentIDs map[string]bool // Track which content IDs we've already displayed + streamSub chan tea.Msg // Subscription channel for streaming events + err error + expectingWorkComplete bool // True when we're expecting work_complete summary content // Clarify tool state awaitingClarification bool // True when waiting for user's clarification answer @@ -163,9 +71,8 @@ type Model struct { clarifyToolCallPath string // The full tool call path for submitting result // Local tool execution - localExecutor *tools.LocalExecutor - toolLogger *logger.Logger - executingToolsMap map[string]int // Maps tool call ID to message index for updating status + localExecutor *tools.LocalExecutor + toolLogger *logger.Logger // Reconnection backoff retryCount int @@ -175,8 +82,17 @@ type Model struct { ctx context.Context cancel context.CancelFunc + // Stream context for cancelling individual stream goroutines + streamCancel context.CancelFunc + // Debug logging debugLogger *log.Logger + + // Command picker for slash commands + commandPicker CommandPicker + + // Highlight animation ticker + highlightTicker *time.Ticker } // NewModel creates a new chat TUI model @@ -186,15 +102,20 @@ func NewModel(client *client.TimAPIClient, personaUID string) Model { // NewModelWithThread creates a new chat TUI model with an optional existing thread ID and debug logger func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string, debugLogger *log.Logger) Model { - ta := textarea.New() - ta.Placeholder = "Type your message..." - ta.Focus() - ta.CharLimit = 4096 - ta.SetWidth(80) - ta.SetHeight(3) - ta.ShowLineNumbers = false + // Initialize spinner for waiting animation + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = loadingStyle + + // Initialize UI components + chatViewport := NewChatViewport(80, 20, &s) + messageInput := NewMessageInput() + sidebar := NewSidebar(30, 24) + statusLine := NewStatusLine() - vp := viewport.New(80, 20) + // Set initial sidebar values + sidebar.SetPersonaUID(personaUID) + sidebar.SetThreadID(threadID) ctx, cancel := context.WithCancel(context.Background()) @@ -223,25 +144,36 @@ func NewModelWithThread(client *client.TimAPIClient, personaUID, threadID string NoTimeout: false, // Use default timeouts }) - return Model{ + m := Model{ client: client, personaUID: personaUID, threadID: threadID, // Can be empty string for new thread - textarea: ta, - viewport: vp, - messages: []string{}, + chatViewport: chatViewport, + messageInput: messageInput, + sidebar: sidebar, + statusLine: statusLine, + spinner: &s, + sidebarWidth: 30, + totalTokens: 0, currentResponse: &strings.Builder{}, // Initialize as pointer displayedContentIDs: make(map[string]bool), localExecutor: localExecutor, toolLogger: toolLogger, - executingToolsMap: make(map[string]int), retryCount: 0, baseDelay: 1 * time.Second, maxDelay: 30 * time.Second, ctx: ctx, cancel: cancel, debugLogger: debugLogger, + commandPicker: NewCommandPicker(), + highlightTicker: nil, } + + // Set the Model as the TUI state provider for the local executor + // This enables TUI awareness tools (get_page, get_elements, highlight_element) + localExecutor.SetTUIState(&m) + + return m } // calculateBackoffWithJitter calculates exponential backoff delay with jitter @@ -275,10 +207,10 @@ func (m Model) Init() tea.Cmd { if m.threadID != "" { threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) m.debugLog("Init: opening stream for existing thread: %s", threadPath) - return tea.Batch(textarea.Blink, m.listenToStream(threadPath)) + return tea.Batch(textarea.Blink, m.spinner.Tick, m.listenToStream(threadPath)) } - return textarea.Blink + return tea.Batch(textarea.Blink, m.spinner.Tick) } // debugLog writes a debug log message if debug logging is enabled @@ -291,23 +223,181 @@ func (m *Model) debugLog(format string, args ...interface{}) { // Update handles messages and updates the model func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( - tiCmd tea.Cmd - vpCmd tea.Cmd + inputCmd tea.Cmd + vpCmd tea.Cmd + spinnerCmd tea.Cmd ) - m.textarea, tiCmd = m.textarea.Update(msg) - m.viewport, vpCmd = m.viewport.Update(msg) - switch msg := msg.(type) { case tea.KeyMsg: m.debugLog("KeyMsg received: %s", msg.String()) + + // Handle command picker navigation when visible BEFORE other updates + if m.commandPicker.IsVisible() { + switch msg.Type { + case tea.KeyEscape: + m.debugLog("Escape pressed, hiding command picker") + m.commandPicker.Hide() + m.updateComponentSizes() + return m, nil + case tea.KeyUp: + m.debugLog("Up arrow pressed, selecting previous command") + m.commandPicker.SelectPrev() + return m, nil + case tea.KeyDown: + m.debugLog("Down arrow pressed, selecting next command") + m.commandPicker.SelectNext() + return m, nil + case tea.KeyTab: + // Tab: autocomplete the selected command + selected := m.commandPicker.GetSelected() + if selected != nil { + m.debugLog("Tab pressed, autocompleting command: %s", selected.Name) + m.messageInput.SetValue("/" + selected.Name) + m.commandPicker.Hide() + m.updateComponentSizes() + // Move cursor to end + m.messageInput.CursorEnd() + } + return m, nil + case tea.KeyEnter: + // Execute selected command + selected := m.commandPicker.GetSelected() + if selected != nil { + m.debugLog("Executing selected command: %s", selected.Name) + m.commandPicker.Hide() + m.updateComponentSizes() + m.messageInput.Reset() + return m, selected.Handler(&m, []string{}) + } + return m, nil + } + } + + case fillElementMsg: + // Handle fillElementMsg BEFORE updating textarea to prevent overwriting + m.debugLog("fillElementMsg RECEIVED: elementName=%s, text=%q, textLen=%d", msg.elementName, msg.text, len(msg.text)) + // Fill the appropriate element with text + if msg.elementName == "message_input" { + m.debugLog("fillElementMsg: setting textarea value to: %q", msg.text) + m.messageInput.SetValue(msg.text) + m.debugLog("fillElementMsg: moving cursor to end") + m.messageInput.CursorEnd() + m.debugLog("fillElementMsg: ensuring textarea is focused") + m.messageInput.Focus() + m.debugLog("fillElementMsg: SUCCESS - filled message_input with text, current value=%q", m.messageInput.Value()) + } else { + m.debugLog("fillElementMsg: ERROR - unknown element %s", msg.elementName) + } + return m, nil + + case highlightTickMsg: + // Advance highlight animation for all components + m.chatViewport.AdvanceHighlight(0.005) + m.messageInput.AdvanceHighlight(0.005) + m.sidebar.AdvanceHighlight(0.005) + m.statusLine.AdvanceHighlight(0.005) + + // Continue ticking if any component is highlighted + if m.chatViewport.IsHighlighted() || m.messageInput.IsHighlighted() || + m.sidebar.IsHighlighted() || m.statusLine.IsHighlighted() { + return m, tea.Tick(16*time.Millisecond, func(t time.Time) tea.Msg { + return highlightTickMsg{} + }) + } + return m, nil + + case highlightElementMsg: + m.debugLog("highlightElementMsg: elementName=%s, enable=%v", msg.elementName, msg.enable) + + // Map element names to components and trigger highlight + switch msg.elementName { + case "message_input": + m.messageInput.Highlight(msg.enable) + case "chat_viewport": + m.chatViewport.Highlight(msg.enable) + case "sidebar": + m.sidebar.Highlight(msg.enable) + case "status_line": + m.statusLine.Highlight(msg.enable) + default: + m.debugLog("highlightElementMsg: unknown element name: %s", msg.elementName) + return m, nil + } + + // Start animation ticker if enabling highlight + if msg.enable { + return m, func() tea.Msg { + return highlightTickMsg{} + } + } + return m, nil + + case clearHighlightMsg: + m.debugLog("clearHighlightMsg: clearing all highlights") + // Turn off highlights for all components + m.messageInput.Highlight(false) + m.chatViewport.Highlight(false) + m.sidebar.Highlight(false) + m.statusLine.Highlight(false) + return m, nil + } + + // Delegate updates to components + inputCmd = m.messageInput.Update(msg) + vpCmd = m.chatViewport.Update(msg) + + // Update spinner (it's a pointer, so we dereference, update, then assign back) + var newSpinner spinner.Model + newSpinner, spinnerCmd = m.spinner.Update(msg) + *m.spinner = newSpinner + + // Update viewport content when spinner updates (for animation) + // Only do this if we have tools executing or are waiting + executingTools := m.chatViewport.GetExecutingTools() + if spinnerCmd != nil && (len(executingTools) > 0 || m.waiting || m.waitingAfterToolCall || m.streamingResponse) { + m.chatViewport.SetWaiting(m.waiting && !m.awaitingClarification || m.waitingAfterToolCall) + } + + // Check if we should show/update the command picker + // Only show picker if not waiting and not in clarification mode + if !m.waiting && !m.awaitingClarification { + currentText := m.messageInput.Value() + trimmedText := strings.TrimSpace(currentText) + + wasVisible := m.commandPicker.IsVisible() + + if strings.HasPrefix(trimmedText, "/") { + // Show or update the command picker with current filter + // Only call Show() if not visible or filter changed + if !m.commandPicker.IsVisible() || m.commandPicker.FilterText != trimmedText { + m.debugLog("Showing/updating command picker with filter: %s", trimmedText) + m.commandPicker.Show(trimmedText) + } + } else if m.commandPicker.IsVisible() && !strings.HasPrefix(trimmedText, "/") { + // Hide picker if text no longer starts with / + m.debugLog("Hiding command picker, text no longer starts with /") + m.commandPicker.Hide() + } + + // Update component sizes if picker visibility changed + if wasVisible != m.commandPicker.IsVisible() { + m.updateComponentSizes() + } + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // KeyMsg already logged in first switch above + switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: + case tea.KeyCtrlC: m.debugLog("Quit key pressed, canceling context") m.cancel() return m, tea.Quit case tea.KeyEnter: + // Enter - send message (Shift+Enter handled by textarea for newlines) if m.awaitingClarification { m.debugLog("Enter pressed during clarification, submitting answer") return m, m.submitClarification() @@ -324,18 +414,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height - // Update viewport size - // Header: 2 lines (title + separator) - // Footer: 8 lines (1 blank + 5 input area with border + 1 blank + 1 help) - headerHeight := 2 - footerHeight := 8 - m.viewport.Width = msg.Width - 4 - m.viewport.Height = msg.Height - headerHeight - footerHeight - - // Update textarea size - m.textarea.SetWidth(msg.Width - 4) - - m.updateViewport() + // Update viewport and textarea sizes + m.updateComponentSizes() case threadCreatedMsg: m.debugLog("threadCreatedMsg: threadID=%s", msg.threadID) @@ -350,66 +430,110 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.debugLog("streamReadyMsg: stream subscription ready") // Stream subscription is ready - store it and wait for first message m.streamSub = msg.sub + m.streamCancel = msg.cancel // Reset retry count on successful connection m.retryCount = 0 - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) case contentStartMsg: - m.debugLog("contentStartMsg: contentID=%s, role=%v, type=%v", msg.contentID, msg.role, msg.contentType) + m.debugLog("contentStartMsg: contentID=%s, role=%v, type=%v, expectingWorkComplete=%v", msg.contentID, msg.role, msg.contentType, m.expectingWorkComplete) // Content block started - track the role and type m.currentContentRole = msg.role m.currentContentType = msg.contentType + // Only process TEXT and THINKING content types for display + // EXCEPTION: If we're expecting work_complete, allow TOOL_USE type (contains the summary) + displayableContent := msg.contentType == threadv1alpha1.ContentType_CONTENT_TYPE_TEXT || + msg.contentType == threadv1alpha1.ContentType_CONTENT_TYPE_THINKING || + m.expectingWorkComplete + // Check if we've already displayed this content (skip replays after reconnect) if m.displayedContentIDs[msg.contentID] { m.debugLog("Content %s already displayed, skipping replay", msg.contentID) // Already displayed - skip this content block m.streamingResponse = false - } else if msg.role == threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT { - m.debugLog("Starting new assistant content block (type=%v)", msg.contentType) + // Clear work_complete flag if it was set + if m.expectingWorkComplete { + m.expectingWorkComplete = false + } + } else if msg.role == threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT && displayableContent { + m.debugLog("Starting new assistant content block (type=%v, isWorkComplete=%v, contentID=%s)", msg.contentType, m.expectingWorkComplete, msg.contentID) // New assistant content - start accumulating m.displayedContentIDs[msg.contentID] = true m.streamingResponse = true + m.currentContentID = msg.contentID // Track which content block we're streaming m.currentResponse.Reset() // Don't add placeholder yet - wait for first content delta to avoid empty blocks + } else if !displayableContent { + m.debugLog("contentStartMsg: skipping non-displayable content type: %v", msg.contentType) + // Don't start streaming for non-displayable content + m.streamingResponse = false } // Continue listening to stream if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } + return m, spinnerCmd case contentDeltaMsg: - m.debugLog("contentDeltaMsg: contentID=%s, deltaLen=%d, streaming=%v", msg.contentID, len(msg.text), m.streamingResponse) + m.debugLog("contentDeltaMsg: contentID=%s, currentContentID=%s, deltaLen=%d, streaming=%v, type=%v, expectingWorkComplete=%v", msg.contentID, m.currentContentID, len(msg.text), m.streamingResponse, m.currentContentType, m.expectingWorkComplete) + + // Ignore deltas that aren't for the current content block we're streaming + if m.currentContentID != msg.contentID { + m.debugLog("contentDeltaMsg: ignoring delta for different content block (current=%s, received=%s)", m.currentContentID, msg.contentID) + // Continue listening to stream + if m.streamSub != nil { + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) + } + return m, spinnerCmd + } + + // Only display TEXT and THINKING content types (skip TOOL_USE) + // EXCEPTION: Allow TOOL_USE if we're expecting work_complete summary + isDisplayableType := m.currentContentType == threadv1alpha1.ContentType_CONTENT_TYPE_TEXT || + m.currentContentType == threadv1alpha1.ContentType_CONTENT_TYPE_THINKING || + m.expectingWorkComplete + + if !isDisplayableType { + m.debugLog("contentDeltaMsg: skipping non-displayable content type: %v", m.currentContentType) + // Continue listening to stream + if m.streamSub != nil { + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) + } + return m, spinnerCmd + } + // Streaming content delta arrived - only process if it's from assistant if m.currentContentRole == threadv1alpha1.LlmMessageRole_LLM_MESSAGE_ROLE_ASSISTANT && m.streamingResponse { // First delta - add the message placeholder if not already added if m.currentResponse.Len() == 0 { + // Clear waiting flag since content is arriving + m.waitingAfterToolCall = false + if m.currentContentType == threadv1alpha1.ContentType_CONTENT_TYPE_THINKING { - m.messages = append(m.messages, thinkingStyle.Render("Thinking: ")) + m.chatViewport.AddMessage(thinkingStyle.Render("Thinking: ")) } else { - m.messages = append(m.messages, assistantMessageStyle.Render("Assistant: ")) + m.chatViewport.AddMessage(assistantMessageStyle.Render("Assistant: ")) } } // Append delta to current response m.currentResponse.WriteString(msg.text) // Update the last message with accumulated text, using appropriate style - lastIdx := len(m.messages) - 1 if m.currentContentType == threadv1alpha1.ContentType_CONTENT_TYPE_THINKING { // For thinking text, use lipgloss Width() to handle wrapping // This ensures style is applied to all lines including wrapped ones fullText := "Thinking: " + m.currentResponse.String() - styledThinking := thinkingStyle.Width(m.viewport.Width).Render(fullText) - m.messages[lastIdx] = styledThinking + styledThinking := thinkingStyle.Width(m.chatViewport.Width()).Render(fullText) + m.chatViewport.UpdateLastMessage(styledThinking) } else { // For assistant text, apply markdown formatting (convert **bold** to actual bold) formattedText := formatMarkdown(m.currentResponse.String()) fullText := "Assistant: " + formattedText // Use Width() to set the wrapping width for the rendered text - styledAssistant := assistantMessageStyle.Width(m.viewport.Width).Render(fullText) - m.messages[lastIdx] = styledAssistant + styledAssistant := assistantMessageStyle.Width(m.chatViewport.Width()).Render(fullText) + m.chatViewport.UpdateLastMessage(styledAssistant) } - m.updateViewport() // Since content is being streamed, allow user input again. // Setting m.waiting = false here enables user input; stream reopening is handled elsewhere. @@ -417,8 +541,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Continue listening to stream if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } + return m, spinnerCmd case toolCallMsg: m.debugLog("toolCallMsg: toolCallID=%s, toolName=%s", msg.toolCallID, msg.toolName) @@ -427,9 +552,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.displayedContentIDs[msg.toolCallID] { m.debugLog("Tool call %s already displayed, skipping replay", msg.toolCallID) if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } - return m, nil + return m, spinnerCmd } m.displayedContentIDs[msg.toolCallID] = true @@ -453,7 +578,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Display the question m.addMessage(systemMessageStyle.Render("Question: ")+question, "") - m.updateViewport() // Enter clarification mode m.awaitingClarification = true @@ -461,30 +585,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clarifyToolCallID = msg.toolCallID m.clarifyToolCallPath = toolCallPath m.waiting = false // Allow user to type - m.textarea.Focus() - m.textarea.Reset() + m.messageInput.Focus() + m.messageInput.Reset() } else if msg.toolName == "work_complete" { - // Handle work_complete tool - display summary - summary, _ := msg.input["summary"].(string) - if summary != "" { - m.debugLog("Displaying work_complete summary") - formattedSummary := formatMarkdown(summary) - fullText := "Assistant: " + formattedSummary - styledSummary := assistantMessageStyle.Width(m.viewport.Width).Render(fullText) - m.messages = append(m.messages, styledSummary) - } else { - m.debugLog("work_complete called but summary is empty") - m.addMessage("[work_complete called but summary is empty]", "system") - } - m.updateViewport() + // work_complete summary will be streamed via content deltas from backend + // Set flag to expect TOOL_USE content that should be displayed + m.debugLog("work_complete tool call received, expecting summary via content deltas") + m.expectingWorkComplete = true } else if actor == llm.ToolActorEnvironment { // Handle environment tools - execute locally m.debugLog("Executing environment tool locally: %s", msg.toolName) // Display tool call toolDisplay := m.formatToolCall(msg.toolName, msg.input) - m.messages = append(m.messages, toolCallStyle.Render(toolDisplay)) - m.updateViewport() + m.chatViewport.AddMessage(toolCallStyle.Render(toolDisplay)) // Send executing message to show loading indicator // Then execute tool asynchronously and submit result @@ -501,64 +615,69 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.streamSub != nil { cmds = append(cmds, waitForMessage(m.streamSub)) } + // Always include spinner to keep animation going + cmds = append(cmds, spinnerCmd) return m, tea.Batch(cmds...) } else { // Display other tool calls (remote/system tools) with a generic format toolDisplay := m.formatToolCall(msg.toolName, msg.input) - m.messages = append(m.messages, toolCallStyle.Render(toolDisplay)) - m.updateViewport() + m.chatViewport.AddMessage(toolCallStyle.Render(toolDisplay)) + m.waitingAfterToolCall = true // Show spinner while waiting for LLM response } // Continue listening to stream if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } + return m, spinnerCmd case toolExecutingMsg: m.debugLog("toolExecutingMsg: toolName=%s, toolCallID=%s", msg.toolName, msg.toolCallID) - // Add a loading indicator message - loadingStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // Yellow - loadingMsg := loadingStyle.Render(fmt.Sprintf(" [...] Executing %s...", msg.toolName)) - m.messages = append(m.messages, loadingMsg) + // Add a loading indicator message with placeholder for spinner + loadingMsg := loadingStyle.Render(fmt.Sprintf(" {SPINNER} Executing %s...", msg.toolName)) + m.chatViewport.AddMessage(loadingMsg) // Track the message index so we can update it later - m.executingToolsMap[msg.toolCallID] = len(m.messages) - 1 - - m.updateViewport() + messages := m.chatViewport.GetMessages() + m.chatViewport.TrackExecutingTool(msg.toolCallID, len(messages)-1) // Continue listening to stream if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } + return m, spinnerCmd case toolResultMsg: m.debugLog("toolResultMsg: toolName=%s, toolCallID=%s, isError=%v, resultLen=%d", msg.toolName, msg.toolCallID, msg.isError, len(msg.result)) // Check if we have a loading indicator to replace - if loadingIdx, exists := m.executingToolsMap[msg.toolCallID]; exists { + executingTools := m.chatViewport.GetExecutingTools() + messages := m.chatViewport.GetMessages() + if loadingIdx, exists := executingTools[msg.toolCallID]; exists { // Replace the loading indicator with the result if msg.isError { - m.messages[loadingIdx] = m.formatToolError(msg.toolName, msg.result) + messages[loadingIdx] = m.formatToolError(msg.toolName, msg.result) } else { - m.messages[loadingIdx] = m.formatToolResult(msg.toolName, msg.result) + messages[loadingIdx] = m.formatToolResult(msg.toolName, msg.result) } - delete(m.executingToolsMap, msg.toolCallID) + m.chatViewport.SetMessages(messages) + m.chatViewport.UntrackExecutingTool(msg.toolCallID) } else { // No loading indicator found, just append if msg.isError { - m.messages = append(m.messages, m.formatToolError(msg.toolName, msg.result)) + m.chatViewport.AddMessage(m.formatToolError(msg.toolName, msg.result)) } else { - m.messages = append(m.messages, m.formatToolResult(msg.toolName, msg.result)) + m.chatViewport.AddMessage(m.formatToolResult(msg.toolName, msg.result)) } } - m.updateViewport() // Continue listening to stream if m.streamSub != nil { - return m, waitForMessage(m.streamSub) + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) } + return m, spinnerCmd case streamCompleteMsg: m.debugLog("streamCompleteMsg: stream ended, reopening (attempt %d)", m.retryCount) @@ -574,8 +693,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.retryCount == 0 { m.debugLog("streamCompleteMsg: first retry, reconnecting immediately") m.retryCount++ - m.addMessage("[Reconnecting...]", "system") - m.updateViewport() // Reconnect immediately if m.threadID != "" { @@ -588,8 +705,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.retryCount++ m.debugLog("streamCompleteMsg: reconnecting in %v", delay) - m.addMessage(fmt.Sprintf("[Reconnecting in %v...]", delay.Round(time.Second)), "system") - m.updateViewport() // Schedule reconnection after backoff delay return m, tea.Tick(delay, func(t time.Time) tea.Msg { @@ -597,8 +712,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } - m.updateViewport() - case reconnectStreamMsg: m.debugLog("reconnectStreamMsg: attempting to reopen stream") // Reopen the stream after backoff delay @@ -607,7 +720,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenToStream(threadPath) } - m.updateViewport() + case threadStateChangeMsg: + m.debugLog("threadStateChangeMsg: state=%v", msg.state) + // When thread goes IDLE, content streaming is complete + if msg.state == threadv1alpha1.ThreadLLMState_THREAD_LLM_STATE_IDLE { + m.streamingResponse = false + m.expectingWorkComplete = false // Clear work_complete flag + } + // Continue listening to stream + if m.streamSub != nil { + return m, tea.Batch(waitForMessage(m.streamSub), spinnerCmd) + } + return m, spinnerCmd + + case slashCommandResultMsg: + m.debugLog("slashCommandResultMsg: messageLen=%d", len(msg.message)) + m.addMessage(msg.message, "system") + m.waiting = false case errMsg: m.debugLog("errMsg: error=%v", msg.err) @@ -627,8 +756,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.retryCount == 0 { m.debugLog("errMsg: first retry, reconnecting immediately") m.retryCount++ - m.addMessage("[Stream error. Reconnecting...]", "system") - m.updateViewport() // Reconnect immediately threadPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) @@ -639,8 +766,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.retryCount++ m.debugLog("errMsg: reconnecting in %v", delay) - m.addMessage(fmt.Sprintf("[Stream error. Reconnecting in %v...]", delay.Round(time.Second)), "system") - m.updateViewport() // Schedule reconnection after backoff delay return m, tea.Tick(delay, func(t time.Time) tea.Msg { @@ -654,10 +779,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.waiting = false m.err = msg.err m.addMessage(fmt.Sprintf("Error: %v", msg.err), "error") - m.updateViewport() } - return m, tea.Batch(tiCmd, vpCmd) + return m, tea.Batch(inputCmd, vpCmd, spinnerCmd) } // View renders the TUI @@ -666,61 +790,142 @@ func (m Model) View() string { return "Loading..." } - // Build the view - var b strings.Builder - - // Header + // Build header + var headerBuilder strings.Builder title := titleStyle.Render("Tim CLI v2 - Interactive Chat") - b.WriteString(title) + headerBuilder.WriteString(title) if m.threadID != "" { threadInfo := helpStyle.Render(fmt.Sprintf(" (Thread: %s)", m.threadID)) - b.WriteString(threadInfo) + headerBuilder.WriteString(threadInfo) } - b.WriteString("\n") - b.WriteString(strings.Repeat("─", m.width)) - b.WriteString("\n") + headerBuilder.WriteString("\n") + headerBuilder.WriteString(strings.Repeat("─", m.chatViewport.Width()+4)) - // Viewport (chat messages) - b.WriteString(m.viewport.View()) - b.WriteString("\n") + // Build help text (without stream status - that goes on status line) + var help string + if m.awaitingClarification { + help = helpStyle.Render("Enter: submit answer • Ctrl+C: quit") + } else if m.waiting { + help = helpStyle.Render("Waiting for response...") + } else { + help = helpStyle.Render("Enter: send • Shift+Enter: new line • type 'exit' or Ctrl+C: quit") + } - // Input area - b.WriteString(inputStyle.Render(m.textarea.View())) - b.WriteString("\n") + // Update status line state + if m.streamSub != nil { + m.statusLine.SetStreamStatus(StreamConnected) + } else if m.retryCount > 0 { + m.statusLine.SetStreamStatus(StreamReconnecting) + } else { + m.statusLine.SetStreamStatus(StreamDisconnected) + } - // Help text - help := helpStyle.Render("Enter: send • Esc: quit") + // Update system state if m.awaitingClarification { - help = helpStyle.Render("Enter: submit answer • Esc: quit") + m.statusLine.SetSystemState(StateClarifying) + } else if m.streamingResponse { + m.statusLine.SetSystemState(StateStreaming) } else if m.waiting { - help = helpStyle.Render("Waiting for response...") + m.statusLine.SetSystemState(StateWaiting) + } else { + m.statusLine.SetSystemState(StateReady) } - b.WriteString(help) - return b.String() + // Render command picker if visible + var commandPickerView string + if m.commandPicker.IsVisible() { + commandPickerView = m.commandPicker.Render(m.chatViewport.Width() + 4) + } + + // Create left column (vertical stack of header, viewport, command picker, input, help, status) + var leftColumnParts []string + leftColumnParts = append(leftColumnParts, headerBuilder.String()) + leftColumnParts = append(leftColumnParts, m.chatViewport.View()) + if commandPickerView != "" { + // Calculate actual picker height based on number of commands + numCommands := len(m.commandPicker.Commands) + actualPickerHeight := 0 + if numCommands > 5 { + actualPickerHeight = 2 + 1 + 1 + 5 + 1 + 1 // with "showing" text = 11 + } else if numCommands > 0 { + actualPickerHeight = 2 + 1 + numCommands + 1 + 1 // = 5 + numCommands + } + + // Maximum picker height (what we reserved space for) + maxPickerHeight := 11 + + // Add padding above the picker to push it down when it's smaller + paddingLines := maxPickerHeight - actualPickerHeight + if paddingLines > 0 { + padding := strings.Repeat("\n", paddingLines) + leftColumnParts = append(leftColumnParts, padding) + } + + leftColumnParts = append(leftColumnParts, commandPickerView) + } + leftColumnParts = append(leftColumnParts, m.messageInput.View()) + leftColumnParts = append(leftColumnParts, help) + leftColumnParts = append(leftColumnParts, m.statusLine.View()) + + leftColumn := lipgloss.JoinVertical( + lipgloss.Left, + leftColumnParts..., + ) + + // Create final layout (left column + sidebar horizontal) + finalLayout := lipgloss.JoinHorizontal( + lipgloss.Top, + leftColumn, + m.sidebar.View(), + ) + + return finalLayout } // sendMessage sends the current message to the API func (m *Model) sendMessage() tea.Cmd { - text := strings.TrimSpace(m.textarea.Value()) + text := strings.TrimSpace(m.messageInput.Value()) if text == "" { m.debugLog("sendMessage: empty text, ignoring") return nil } + // Check if it's a slash command + if command, args, isCommand := parseSlashCommand(text); isCommand { + m.debugLog("sendMessage: slash command detected: %s", command) + // Find and execute the command + cmd := findCommand(command) + if cmd != nil { + m.debugLog("sendMessage: executing command: %s", cmd.Name) + m.messageInput.Reset() + return cmd.Handler(m, args) + } else { + m.debugLog("sendMessage: unknown command: %s", command) + m.addMessage(fmt.Sprintf("Unknown command: /%s. Type /help for available commands.", command), "error") + m.messageInput.Reset() + return nil + } + } + + // Check if user wants to exit + if text == "exit" || text == "quit" { + m.debugLog("sendMessage: exit command received, quitting") + m.cancel() + return tea.Quit + } + m.debugLog("sendMessage: text=%q, threadID=%s", text, m.threadID) // Add user message to display m.addMessage(text, "user") - m.textarea.Reset() + m.messageInput.Reset() m.waiting = true - m.updateViewport() return m.streamResponse(text) } // submitClarification submits the user's clarification answer as a tool result func (m *Model) submitClarification() tea.Cmd { - answer := strings.TrimSpace(m.textarea.Value()) + answer := strings.TrimSpace(m.messageInput.Value()) if answer == "" { m.debugLog("submitClarification: empty answer, ignoring") return nil @@ -734,7 +939,7 @@ func (m *Model) submitClarification() tea.Cmd { // Display the user's answer m.addMessage(answer, "user") - m.textarea.Reset() + m.messageInput.Reset() m.waiting = true m.awaitingClarification = false @@ -743,8 +948,6 @@ func (m *Model) submitClarification() tea.Cmd { m.clarifyToolCallID = "" m.clarifyToolCallPath = "" - m.updateViewport() - // Submit the tool result - stream stays open and will receive new events return func() tea.Msg { m.debugLog("submitClarification: submitting tool result") @@ -819,8 +1022,19 @@ func (m *Model) streamResponse(query string) tea.Cmd { func (m *Model) listenToStream(threadPath string) tea.Cmd { return func() tea.Msg { m.debugLog("listenToStream: opening stream for path=%s", threadPath) - stream, err := m.client.StreamThreadEvents(m.ctx, threadPath) + + // Cancel any existing stream context + if m.streamCancel != nil { + m.debugLog("listenToStream: cancelling previous stream context") + m.streamCancel() + } + + // Create new stream context + streamCtx, streamCancel := context.WithCancel(m.ctx) + + stream, err := m.client.StreamThreadEvents(streamCtx, threadPath) if err != nil { + streamCancel() m.debugLog("listenToStream: failed to open stream: %v", err) return errMsg{err: fmt.Errorf("failed to open stream: %w", err)} } @@ -832,12 +1046,26 @@ func (m *Model) listenToStream(threadPath string) tea.Cmd { go func() { defer stream.Close() defer close(sub) + defer streamCancel() m.debugLog("listenToStream: goroutine started for receiving events") eventCount := 0 for { + // Check if context was cancelled + select { + case <-streamCtx.Done(): + m.debugLog("listenToStream: stream context cancelled, exiting goroutine") + return + default: + } + event, err := stream.Receive() if err != nil { + // Check if error is due to context cancellation + if streamCtx.Err() != nil { + m.debugLog("listenToStream: stream context cancelled during receive") + return + } m.debugLog("listenToStream: stream error after %d events: %v", eventCount, err) sub <- errMsg{err: fmt.Errorf("stream error: %w", err)} return @@ -902,16 +1130,17 @@ func (m *Model) listenToStream(threadPath string) tea.Cmd { case *threadv1alpha1.StreamThreadEventsResponse_ThreadStateChange: m.debugLog("listenToStream: ThreadStateChange event - state=%v", evt.ThreadStateChange.LlmState) - // Don't close stream when thread goes IDLE - // The stream stays open for the entire session - // Just continue listening for the next message/tool call + // Send state change message to update UI state + sub <- threadStateChangeMsg{ + state: evt.ThreadStateChange.LlmState, + } } } }() // Return message indicating stream is ready with the subscription channel m.debugLog("listenToStream: returning streamReadyMsg") - return streamReadyMsg{sub: sub} + return streamReadyMsg{sub: sub, cancel: streamCancel} } } @@ -942,14 +1171,45 @@ func (m *Model) addMessage(text, msgType string) { default: styled = text } - m.messages = append(m.messages, styled) + m.chatViewport.AddMessage(styled) + // Update sidebar message count + messages := m.chatViewport.GetMessages() + m.sidebar.SetMessageCount(len(messages)) } -// updateViewport updates the viewport content with all messages -func (m *Model) updateViewport() { - content := strings.Join(m.messages, "\n\n") - m.viewport.SetContent(content) - m.viewport.GotoBottom() +// updateComponentSizes updates the sizes of viewport and textarea based on screen size and picker visibility +func (m *Model) updateComponentSizes() { + if m.width == 0 || m.height == 0 { + return + } + + // Header: 2 lines (title + separator) + 1 join newline + // Footer: input (5) + help (1) + status (1) + 3 join newlines between viewport-input-help-status + // Sidebar: sidebarWidth + 3 (1 for separator, 2 for padding) + headerHeight := 3 // 2 for header + 1 for join + footerHeight := 10 // 5 (input) + 1 (help) + 1 (status) + 3 (joins) + sidebarSpace := m.sidebarWidth + 3 + + // Calculate command picker height if visible + // Always reserve maximum height to prevent text input from jumping + pickerHeight := 0 + if m.commandPicker.IsVisible() { + // Always use maximum picker height (with 5 commands + "showing" text) + // This prevents the viewport from expanding/contracting as the list is filtered + pickerHeight = 2 + 1 + 1 + 5 + 1 + 1 // = 11 lines + } + + // Adjust viewport height to account for picker + m.chatViewport.SetSize(m.width-4-sidebarSpace, m.height-headerHeight-footerHeight-pickerHeight) + + // Update textarea size (also account for sidebar) + m.messageInput.SetSize(m.width-4-sidebarSpace, 0) + + // Update sidebar size + m.sidebar.SetSize(m.sidebarWidth, m.height) + + // Keep viewport scrolled to bottom after resize + m.chatViewport.GotoBottom() } // formatMarkdown converts markdown-style formatting to terminal ANSI codes @@ -969,7 +1229,7 @@ func formatMarkdown(text string) string { // executeLocalTool executes an environment tool locally and submits the result func (m *Model) executeLocalTool(toolCallID, toolName string, input map[string]interface{}) tea.Cmd { return func() tea.Msg { - m.debugLog("executeLocalTool: toolCallID=%s, toolName=%s", toolCallID, toolName) + m.debugLog("executeLocalTool START: toolCallID=%s, toolName=%s, input=%+v", toolCallID, toolName, input) // Build tool call path for submitting result toolCallPath := apiclient.BuildThreadPath(m.client.GetOrgID(), m.client.GetUserID(), m.threadID) + "/toolCalls/" + toolCallID @@ -1050,6 +1310,109 @@ func (m *Model) executeLocalTool(toolCallID, toolName string, input map[string]i m.debugLog("executeLocalTool: tool result submitted successfully: messagePath=%s", resp.Message) + // Check if this was a fill_element tool - if so, extract the parameters and send fillElementMsg + // We do this by checking the tool name and extracting from input params directly + // (Can't rely on shared state due to Bubble Tea's value-based Update signature) + if toolName == "fill_element" && !result.IsError { + m.debugLog("executeLocalTool: detected fill_element tool, extracting parameters from input") + + elementName, elementNameOk := input["element_name"].(string) + text, textOk := input["text"].(string) + + m.debugLog("executeLocalTool: fill_element params - elementName=%s (ok=%v), text=%q (ok=%v)", elementName, elementNameOk, text, textOk) + + if elementNameOk && textOk { + m.debugLog("executeLocalTool: FILL_ELEMENT DETECTED! Creating batched commands (toolResult + fillElement)") + + // Return both the tool result and fill element message + batchedCmd := tea.Batch( + func() tea.Msg { + m.debugLog("executeLocalTool: returning toolResultMsg for fill_element") + return toolResultMsg{ + toolName: toolName, + toolCallID: toolCallID, + result: result.Result, + isError: result.IsError, + } + }, + func() tea.Msg { + m.debugLog("executeLocalTool: returning fillElementMsg: elementName=%s, text=%q", elementName, text) + return fillElementMsg{ + elementName: elementName, + text: text, + } + }, + ) + m.debugLog("executeLocalTool: batch created for fill_element, executing now") + return batchedCmd() + } else { + m.debugLog("executeLocalTool: fill_element params extraction failed") + } + } + + // Check if this was a highlight_element tool - if so, extract the parameters and send highlightElementMsg + if toolName == "highlight_element" && !result.IsError { + m.debugLog("executeLocalTool: detected highlight_element tool, extracting parameters from input") + + elementName, elementNameOk := input["element_name"].(string) + + m.debugLog("executeLocalTool: highlight_element params - elementName=%s (ok=%v)", elementName, elementNameOk) + + if elementNameOk { + m.debugLog("executeLocalTool: HIGHLIGHT_ELEMENT DETECTED! Creating batched commands (toolResult + highlightElement)") + + // Return both the tool result and highlight element message + batchedCmd := tea.Batch( + func() tea.Msg { + m.debugLog("executeLocalTool: returning toolResultMsg for highlight_element") + return toolResultMsg{ + toolName: toolName, + toolCallID: toolCallID, + result: result.Result, + isError: result.IsError, + } + }, + func() tea.Msg { + m.debugLog("executeLocalTool: returning highlightElementMsg: elementName=%s", elementName) + return highlightElementMsg{ + elementName: elementName, + enable: true, + } + }, + ) + m.debugLog("executeLocalTool: batch created for highlight_element, executing now") + return batchedCmd() + } else { + m.debugLog("executeLocalTool: highlight_element params extraction failed") + } + } + + // Check if this was a clear_highlighting tool - if so, send clearHighlightMsg + if toolName == "clear_highlighting" && !result.IsError { + m.debugLog("executeLocalTool: CLEAR_HIGHLIGHTING DETECTED! Creating batched commands (toolResult + clearHighlight)") + + // Return both the tool result and clear highlight message + batchedCmd := tea.Batch( + func() tea.Msg { + m.debugLog("executeLocalTool: returning toolResultMsg for clear_highlighting") + return toolResultMsg{ + toolName: toolName, + toolCallID: toolCallID, + result: result.Result, + isError: result.IsError, + } + }, + func() tea.Msg { + m.debugLog("executeLocalTool: returning clearHighlightMsg") + return clearHighlightMsg{} + }, + ) + m.debugLog("executeLocalTool: batch created for clear_highlighting, executing now") + return batchedCmd() + } + + m.debugLog("executeLocalTool: returning normal toolResultMsg") + // Return toolResultMsg to display the result in the TUI return toolResultMsg{ toolName: toolName, @@ -1062,9 +1425,6 @@ func (m *Model) executeLocalTool(toolCallID, toolName string, input map[string]i // formatToolError formats a tool error for display func (m *Model) formatToolError(toolName, errorMsg string) string { - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) // Bright red, bold - detailStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // Lighter red for details - var b strings.Builder // Error header diff --git a/tim-cli-v2/internal/tui/sidebar.go b/tim-cli-v2/internal/tui/sidebar.go new file mode 100644 index 000000000..7d7a5b0ca --- /dev/null +++ b/tim-cli-v2/internal/tui/sidebar.go @@ -0,0 +1,169 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Sidebar displays thread metadata and system information +type Sidebar struct { + highlight HighlightState + width int + height int + threadID string + personaUID string + messageCount int + tokenCount int +} + +// NewSidebar creates a new sidebar component +func NewSidebar(width, height int) *Sidebar { + return &Sidebar{ + highlight: HighlightState{}, + width: width, + height: height, + threadID: "", + personaUID: "", + messageCount: 0, + tokenCount: 0, + } +} + +// Update implements the Component interface +func (s *Sidebar) Update(msg tea.Msg) tea.Cmd { + // Sidebar doesn't handle any messages directly + return nil +} + +// View implements the Component interface +func (s *Sidebar) View() string { + var b strings.Builder + + // TIM ASCII art logo with gradient (dark golden orange to dark gray) + logoLines := []string{ + "████████╗██╗███╗ ███╗", + "╚══██╔══╝██║████╗ ████║", + " ██║ ██║██╔████╔██║", + " ██║ ██║██║╚██╔╝██║", + " ██║ ██║██║ ╚═╝ ██║", + " ╚═╝ ╚═╝╚═╝ ╚═╝", + } + // Gradient colors from dark golden orange (172) to dark gray (240) + gradientColors := []string{"214", "208", "166", "124", "088", "052"} + + for i, line := range logoLines { + coloredLine := lipgloss.NewStyle().Foreground(lipgloss.Color(gradientColors[i])).Render(line) + b.WriteString(coloredLine) + b.WriteString("\n") + } + b.WriteString("\n") + + // Sidebar header + b.WriteString(systemMessageStyle.Render("INFO")) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", s.width-2)) + b.WriteString("\n") + + // Thread ID (truncated if too long) + threadID := s.threadID + if threadID == "" { + threadID = "None" + } else if len(threadID) > s.width-11 { + threadID = threadID[:s.width-14] + "..." + } + b.WriteString(helpStyle.Render(fmt.Sprintf("Thread: %s", threadID))) + b.WriteString("\n") + + // Persona UID (truncated if too long) + personaUID := s.personaUID + if len(personaUID) > s.width-11 { + personaUID = personaUID[:s.width-14] + "..." + } + b.WriteString(helpStyle.Render(fmt.Sprintf("Persona: %s", personaUID))) + b.WriteString("\n") + + // Message count + b.WriteString(helpStyle.Render(fmt.Sprintf("Messages: %d", s.messageCount))) + b.WriteString("\n") + + // Token count + b.WriteString(helpStyle.Render(fmt.Sprintf("Tokens: %d", s.tokenCount))) + b.WriteString("\n") + + // Apply border with highlight if enabled + // Dark red/brown (88: #870000) to bright orange (214: #ffaf00) + borderColor := s.highlight.GetBorderColor("208", 135, 0, 0, 255, 175, 0) + + // Use block border when highlighted, rounded border otherwise + borderStyle := lipgloss.RoundedBorder() + if s.highlight.IsHighlighted { + borderStyle = lipgloss.BlockBorder() + } + + sidebarStyle := lipgloss.NewStyle(). + Border(borderStyle). + BorderForeground(borderColor). + Width(s.width). + Height(s.height-2). // Full height minus borders + Padding(0, 1) + + return sidebarStyle.Render(b.String()) +} + +// Highlight implements the Component interface +func (s *Sidebar) Highlight(enable bool) { + if enable { + s.highlight.StartHighlight() + } else { + s.highlight.StopHighlight() + } +} + +// IsHighlighted implements the Component interface +func (s *Sidebar) IsHighlighted() bool { + return s.highlight.IsHighlighted +} + +// AdvanceHighlight implements the Component interface +func (s *Sidebar) AdvanceHighlight(delta float64) { + s.highlight.Advance(delta) +} + +// SetSize implements the Sizable interface +func (s *Sidebar) SetSize(width, height int) { + s.width = width + s.height = height +} + +// SetThreadID updates the displayed thread ID +func (s *Sidebar) SetThreadID(threadID string) { + s.threadID = threadID +} + +// SetPersonaUID updates the displayed persona UID +func (s *Sidebar) SetPersonaUID(personaUID string) { + s.personaUID = personaUID +} + +// SetMessageCount updates the displayed message count +func (s *Sidebar) SetMessageCount(count int) { + s.messageCount = count +} + +// SetTokenCount updates the displayed token count +func (s *Sidebar) SetTokenCount(count int) { + s.tokenCount = count +} + +// Width returns the sidebar width +func (s *Sidebar) Width() int { + return s.width +} + +// Height returns the sidebar height +func (s *Sidebar) Height() int { + return s.height +} diff --git a/tim-cli-v2/internal/tui/status_line.go b/tim-cli-v2/internal/tui/status_line.go new file mode 100644 index 000000000..8ee791c76 --- /dev/null +++ b/tim-cli-v2/internal/tui/status_line.go @@ -0,0 +1,139 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SystemState represents the current state of the TUI +type SystemState int + +const ( + StateReady SystemState = iota + StateWaiting + StateStreaming + StateClarifying +) + +// StreamStatus represents the connection status +type StreamStatus int + +const ( + StreamDisconnected StreamStatus = iota + StreamConnected + StreamReconnecting +) + +// StatusLine displays stream connection status and system state +type StatusLine struct { + highlight HighlightState + streamStatus StreamStatus + systemState SystemState +} + +// NewStatusLine creates a new status line component +func NewStatusLine() *StatusLine { + return &StatusLine{ + highlight: HighlightState{}, + streamStatus: StreamDisconnected, + systemState: StateReady, + } +} + +// Update implements the Component interface +func (s *StatusLine) Update(msg tea.Msg) tea.Cmd { + // StatusLine doesn't handle any messages directly + return nil +} + +// View implements the Component interface +func (s *StatusLine) View() string { + var statusLine strings.Builder + + // Define muted color styles + mutedGreen := lipgloss.NewStyle().Foreground(lipgloss.Color("71")) // Muted green + mutedGray := lipgloss.NewStyle().Foreground(lipgloss.Color("243")) // Muted gray + mutedYellow := lipgloss.NewStyle().Foreground(lipgloss.Color("179")) // Muted yellow + mutedOrange := lipgloss.NewStyle().Foreground(lipgloss.Color("173")) // Muted orange + + // Stream connection status + statusLine.WriteString(helpStyle.Render("Stream: ")) + switch s.streamStatus { + case StreamConnected: + statusLine.WriteString(mutedGreen.Render("● Connected")) + case StreamReconnecting: + statusLine.WriteString(mutedYellow.Render("◐ Reconnecting")) + case StreamDisconnected: + statusLine.WriteString(mutedGray.Render("○ Disconnected")) + } + + // System state + var state string + var stateStyle lipgloss.Style + switch s.systemState { + case StateClarifying: + state = "Clarifying" + stateStyle = mutedYellow + case StateStreaming: + state = "Streaming" + stateStyle = mutedOrange + case StateWaiting: + state = "Waiting" + stateStyle = mutedYellow + case StateReady: + state = "Ready" + stateStyle = mutedGreen + } + statusLine.WriteString(" │ ") + statusLine.WriteString(stateStyle.Render("Status: " + state)) + + // Apply background highlight if enabled + bgColor := s.highlight.GetBackgroundColor(208, 214) + style := lipgloss.NewStyle() + if s.highlight.IsHighlighted { + style = style.Background(bgColor) + } + + return style.Render(statusLine.String()) +} + +// Highlight implements the Component interface +func (s *StatusLine) Highlight(enable bool) { + if enable { + s.highlight.StartHighlight() + } else { + s.highlight.StopHighlight() + } +} + +// IsHighlighted implements the Component interface +func (s *StatusLine) IsHighlighted() bool { + return s.highlight.IsHighlighted +} + +// AdvanceHighlight implements the Component interface +func (s *StatusLine) AdvanceHighlight(delta float64) { + s.highlight.Advance(delta) +} + +// SetStreamStatus updates the stream connection status +func (s *StatusLine) SetStreamStatus(status StreamStatus) { + s.streamStatus = status +} + +// SetSystemState updates the system state +func (s *StatusLine) SetSystemState(state SystemState) { + s.systemState = state +} + +// GetStreamStatus returns the current stream status +func (s *StatusLine) GetStreamStatus() StreamStatus { + return s.streamStatus +} + +// GetSystemState returns the current system state +func (s *StatusLine) GetSystemState() SystemState { + return s.systemState +} diff --git a/tim-cli-v2/internal/tui/styles.go b/tim-cli-v2/internal/tui/styles.go new file mode 100644 index 000000000..26cb51afb --- /dev/null +++ b/tim-cli-v2/internal/tui/styles.go @@ -0,0 +1,53 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("214")). + MarginLeft(1) + + userMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + assistantMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("220")) // Gold + + boldStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("208")). // Deep orange + Bold(true) + + thinkingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). // Brighter gray + Italic(true) + + systemMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Italic(true) + + toolCallStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("99")). // Purple + Italic(true) + + toolResultStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")). // Gray + Italic(true) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + loadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")) // Yellow + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). // Bright red + Bold(true) + + detailStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("203")) // Lighter red for details +) diff --git a/tim-cli-v2/internal/tui/tui_state.go b/tim-cli-v2/internal/tui/tui_state.go new file mode 100644 index 000000000..40deff359 --- /dev/null +++ b/tim-cli-v2/internal/tui/tui_state.go @@ -0,0 +1,118 @@ +package tui + +import ( + "context" + "fmt" + + "github.com/Greybox-Labs/tim/shared/tools" +) + +// Implement TUIStateProvider interface on Model + +// GetPageInfo returns the name and description of the current TUI view/page +func (m *Model) GetPageInfo(ctx context.Context) (name string, description string, err error) { + // TODO: Fill in actual page information + name = "Chat View" + description = "Interactive chat interface with the Tim assistant. This is the main conversation view where you can send messages and see responses." + return name, description, nil +} + +// GetElements returns all visible elements (components and slash commands) +func (m *Model) GetElements(ctx context.Context) ([]tools.TUIElement, error) { + elements := []tools.TUIElement{} + + // TODO: Fill in actual element descriptions and locations + + // UI Components + elements = append(elements, tools.TUIElement{ + Name: "message_input", + Type: "text_input", + Description: "Text input area for typing messages to send to the assistant", + Location: "Near the bottom of main screen, above the help text", + IsHighlighted: m.messageInput.IsHighlighted(), + }) + + elements = append(elements, tools.TUIElement{ + Name: "chat_viewport", + Type: "component", + Description: "Scrollable area displaying the conversation history", + Location: "Main area in the center of the screen", + IsHighlighted: m.chatViewport.IsHighlighted(), + }) + + elements = append(elements, tools.TUIElement{ + Name: "sidebar", + Type: "component", + Description: "Information panel showing thread ID, persona, message count, and token count", + Location: "Right side of the screen", + IsHighlighted: m.sidebar.IsHighlighted(), + }) + + elements = append(elements, tools.TUIElement{ + Name: "status_line", + Type: "component", + Description: "Shows stream connection status and system state (Ready, Waiting, Streaming, etc.)", + Location: "Bottom of screen, below the message input", + IsHighlighted: m.statusLine.IsHighlighted(), + }) + + // Slash Commands + commands := getSlashCommands() + for _, cmd := range commands { + elements = append(elements, tools.TUIElement{ + Name: "/" + cmd.Name, + Type: "slash_command", + Description: cmd.Description, + Location: "Available when typing '/' in the message input", + }) + } + + return elements, nil +} + +// HighlightElement triggers a highlight effect on the specified element +// Note: This just validates - the actual highlighting happens via highlightElementMsg in Update() +func (m *Model) HighlightElement(ctx context.Context, elementName string) error { + m.debugLog("HighlightElement: elementName=%s (validation only, actual highlight via message)", elementName) + + // Validate that the element can be highlighted + validElements := map[string]bool{ + "message_input": true, + "chat_viewport": true, + "sidebar": true, + "status_line": true, + } + + if !validElements[elementName] { + return fmt.Errorf("element '%s' cannot be highlighted (only text_input and component types supported)", elementName) + } + + m.debugLog("HighlightElement: validation passed") + return nil +} + +// FillElement fills text into a text input element +// Note: This just validates - the actual filling happens via fillElementMsg in Update() +func (m *Model) FillElement(ctx context.Context, elementName string, text string) error { + m.debugLog("FillElement: elementName=%s, text=%q (validation only, actual fill via message)", elementName, text) + + // Just validate that the element is fillable + // The actual UI update will be triggered by executeLocalTool sending a fillElementMsg + // This is necessary because Bubble Tea requires all UI updates to happen in Update() + + if elementName != "message_input" { + return fmt.Errorf("element '%s' is not a fillable text input", elementName) + } + + m.debugLog("FillElement: validation passed") + return nil +} + +// ClearHighlight clears all component highlights +// Note: This just validates - the actual clearing happens via clearHighlightMsg in Update() +func (m *Model) ClearHighlight(ctx context.Context) error { + m.debugLog("ClearHighlight: validation only, actual clear via message") + // Always succeeds - no validation needed + return nil +} + diff --git a/tim-db/gen/schema/schema.sql b/tim-db/gen/schema/schema.sql index 90c5a2589..7b31c1749 100644 --- a/tim-db/gen/schema/schema.sql +++ b/tim-db/gen/schema/schema.sql @@ -1124,4 +1124,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20251010000000'), ('20251014000000'), ('20251029205643'), - ('20251103221217'); + ('20251103221217'), + ('20251106223554'); diff --git a/tim-db/migrations/20251029205643_add_developer_persona_with_all_tools.sql b/tim-db/migrations/20251029205643_add_developer_persona_with_all_tools.sql index 035ee5578..8b8621276 100644 --- a/tim-db/migrations/20251029205643_add_developer_persona_with_all_tools.sql +++ b/tim-db/migrations/20251029205643_add_developer_persona_with_all_tools.sql @@ -24,37 +24,41 @@ INSERT INTO persona_revision ( ) VALUES ( uuid_generate_v5(uuid_ns_oid(), 'tim.persona.Developer'), 'You are an expert software development assistant with comprehensive access to the local development environment. You can read and write files, execute shell commands, search code, and manage processes. Always verify file paths and commands before execution. Ask for confirmation before running potentially destructive operations. Use your tools effectively to help the user with coding, debugging, and system tasks.', - '{ - "clarify", - "work_complete", - "inform_user", - "web_search", - "fetch_webpage", - "delegate_work", - "file_read", - "file_write", - "file_edit", - "file_list", - "exec_command", - "list_processes", - "kill_process", - "process_output", - "glob", - "search_code", - "gather", - "get_environment", - "memory_forget", - "memory_remember", - "list_todos", - "add_todo", - "start_todo", - "complete_todo", - "update_todo_priority", - "remove_todo", - "list_connected_apps", - "list_connected_app_actions", - "execute_connected_app_action" - }', + ARRAY[ + 'clarify', + 'work_complete', + 'inform_user', + 'web_search', + 'fetch_webpage', + 'delegate_work', + 'file_read', + 'file_write', + 'file_edit', + 'file_list', + 'exec_command', + 'list_processes', + 'kill_process', + 'process_output', + 'glob', + 'search_code', + 'gather', + 'get_environment', + 'get_page', + 'get_elements', + 'highlight_element', + 'fill_element', + 'memory_forget', + 'memory_remember', + 'list_todos', + 'add_todo', + 'start_todo', + 'complete_todo', + 'update_todo_priority', + 'remove_todo', + 'list_connected_apps', + 'list_connected_app_actions', + 'execute_connected_app_action' + ]::VARCHAR(255)[], 'claude-4-5-sonnet', 'final' ); diff --git a/tim-db/migrations/20251106223554_add_tui_tools_to_developer_persona.sql b/tim-db/migrations/20251106223554_add_tui_tools_to_developer_persona.sql new file mode 100644 index 000000000..ed8917a95 --- /dev/null +++ b/tim-db/migrations/20251106223554_add_tui_tools_to_developer_persona.sql @@ -0,0 +1,22 @@ +-- migrate:up + +-- Add TUI awareness tools (get_page, get_elements, highlight_element, fill_element, clear_highlighting) to Developer persona +-- These tools allow the LLM to understand and interact with the TUI interface + +UPDATE persona_revision +SET tools = tools || ARRAY['get_page', 'get_elements', 'highlight_element', 'fill_element', 'clear_highlighting']::VARCHAR(255)[] +WHERE persona_uid = uuid_generate_v5(uuid_ns_oid(), 'tim.persona.Developer') + AND state = 'final' + AND NOT (tools @> ARRAY['get_page']::VARCHAR(255)[]); -- Only update if tools not already present + +-- migrate:down + +-- Remove TUI awareness tools from Developer persona +UPDATE persona_revision +SET tools = ARRAY( + SELECT unnest(tools) + EXCEPT + SELECT unnest(ARRAY['get_page', 'get_elements', 'highlight_element', 'fill_element', 'clear_highlighting']::VARCHAR(255)[]) +) +WHERE persona_uid = uuid_generate_v5(uuid_ns_oid(), 'tim.persona.Developer') + AND state = 'final'; diff --git a/tim-worker/tim-cli-v2 b/tim-worker/tim-cli-v2 new file mode 100755 index 000000000..245c2cb34 Binary files /dev/null and b/tim-worker/tim-cli-v2 differ