mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 19:50:13 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50e7b78c33 | |||
| b937af3056 | |||
| a5e995c750 | |||
| e95e08a699 | |||
| bcaf92f62a | |||
| ead4afbfe6 | |||
| 685aaf207f | |||
| 76ff6c9639 | |||
| 1cf24ee5de | |||
| c9637090fa | |||
| 0ff0ff42ab | |||
| a4fb32ff2b | |||
| 7d2f078111 | |||
| b0b66941ab | |||
| cbb7387a72 | |||
| 19430b0ecb | |||
| 8e3cfeede5 | |||
| 4fa5775974 | |||
| 4e7d823ee4 | |||
| 7a16c76adc | |||
| 70a21ee73a | |||
| 28d2de8f39 | |||
| 7f192ae850 | |||
| 9f6746ded9 | |||
| 7514d3a0ff | |||
| c83281a52b | |||
| 4515bb92c2 | |||
| e326b84204 | |||
| 1b93049b8e | |||
| 4912449dda | |||
| b70cce4f34 | |||
| 4c566836b2 | |||
| bb3261883a | |||
| 512d0f16ce | |||
| 8159431ce4 | |||
| 9f9f265fb3 |
@@ -0,0 +1,79 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or issue with Kit
|
||||
title: "fix: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: What happened? What did you expect to happen?
|
||||
placeholder: |
|
||||
The BorderColor field in ToolRenderConfig is documented but never applied
|
||||
during tool rendering. I expected the tool block to render with my custom
|
||||
color, but it uses the default styling instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide clear steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. Create an extension with `api.RegisterToolRenderer(ext.ToolRenderConfig{...})`
|
||||
2. Set `BorderColor: "#89b4fa"` in the config
|
||||
3. Run a tool that uses this renderer
|
||||
4. Observe the border color is not applied
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: code
|
||||
attributes:
|
||||
label: Relevant Code / Configuration
|
||||
description: Paste any code, configuration, or error messages
|
||||
placeholder: |
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell",
|
||||
BorderColor: "#a6e3a1", // This is ignored!
|
||||
Background: "#1e1e2e", // This is ignored!
|
||||
})
|
||||
```
|
||||
render: go
|
||||
|
||||
- type: input
|
||||
id: component
|
||||
attributes:
|
||||
label: Affected Component
|
||||
description: Which part of Kit is affected?
|
||||
placeholder: e.g., extensions, ui, tool rendering, session management
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Kit Version
|
||||
description: What version of Kit are you running?
|
||||
placeholder: e.g., v0.1.0, commit hash, or "main"
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, proposed fixes, or related issues
|
||||
placeholder: |
|
||||
The issue appears to be in `internal/ui/messages.go:RenderToolMessage()`
|
||||
which ignores the BorderColor and Background fields from ToolRendererData.
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I've searched existing issues and this hasn't been reported yet
|
||||
required: true
|
||||
- label: I've tested with the latest version of Kit
|
||||
required: false
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Kit Documentation
|
||||
url: https://github.com/mark3labs/kit/tree/main/www/pages
|
||||
about: Check the documentation before filing an issue
|
||||
- name: Extension Examples
|
||||
url: https://github.com/mark3labs/kit/tree/main/examples/extensions
|
||||
about: See working extension examples for reference
|
||||
- name: Discussions
|
||||
url: https://github.com/mark3labs/kit/discussions
|
||||
about: For questions, ideas, or general discussion
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Documentation Issue
|
||||
description: Report missing, incorrect, or unclear documentation
|
||||
title: "docs: "
|
||||
labels: ["documentation"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Documentation Issue
|
||||
description: What's wrong or missing in the documentation?
|
||||
placeholder: |
|
||||
The ToolRenderConfig documentation mentions BorderColor and Background fields,
|
||||
but the code doesn't actually use them. The docs should either be updated
|
||||
to reflect reality, or the bug should be fixed.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Documentation Location
|
||||
description: Where is the affected documentation?
|
||||
placeholder: e.g., README.md, examples/extensions/tool-renderer-demo.go, pkg/kit docs
|
||||
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: Suggested Improvement
|
||||
description: How should the documentation be improved?
|
||||
placeholder: |
|
||||
Add a note that BorderColor and Background are not yet implemented,
|
||||
or fix the bug and document the correct behavior.
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I've checked that this documentation issue still exists in the latest version
|
||||
required: true
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Kit
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: What would you like to see added or changed?
|
||||
placeholder: |
|
||||
I'd like to be able to customize the border color of tool result blocks
|
||||
dynamically based on the tool type or result status.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / Use Case
|
||||
description: Why is this feature needed? What problem does it solve?
|
||||
placeholder: |
|
||||
When running multiple tools in sequence, it's hard to visually distinguish
|
||||
between file reads (blue), shell commands (green), and errors (red)
|
||||
without custom border colors.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed
|
||||
attributes:
|
||||
label: Proposed Implementation
|
||||
description: How do you think this should work? (optional)
|
||||
placeholder: |
|
||||
Extend `ToolRenderConfig` to accept a function that receives the tool
|
||||
result and returns a color based on the content:
|
||||
|
||||
```go
|
||||
BorderColorFunc: func(result string, isError bool) string {
|
||||
if isError {
|
||||
return "#f38ba8"
|
||||
}
|
||||
return "#89b4fa"
|
||||
}
|
||||
```
|
||||
render: go
|
||||
|
||||
- type: checkboxes
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
options:
|
||||
- label: I've considered workarounds or alternative approaches
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I've searched existing issues and this hasn't been requested yet
|
||||
required: true
|
||||
- label: This feature aligns with Kit's design philosophy (TUI-first, extension-based)
|
||||
required: false
|
||||
@@ -28,11 +28,15 @@ type lintResult struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// Package-level state: set of .go files edited during the current agent turn.
|
||||
var editedFiles map[string]bool
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint on Go file edits")
|
||||
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint after agent turns that edit Go files")
|
||||
})
|
||||
|
||||
// Track edited .go files — don't lint yet.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
if e.IsError || !isEditOrWrite(e.ToolName) {
|
||||
return nil
|
||||
@@ -43,30 +47,72 @@ func Init(api ext.API) {
|
||||
return nil
|
||||
}
|
||||
|
||||
report := runGoDiagnostics(ctx.CWD, absPath)
|
||||
|
||||
// Check if there are issues and add explicit prompt for the LLM to react
|
||||
goplsIssues, lintIssues := countIssues(report)
|
||||
hasIssues := goplsIssues > 0 || lintIssues > 0
|
||||
|
||||
var enhanced string
|
||||
if hasIssues {
|
||||
enhanced = e.Content + "\n\n" + report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review the issues above and fix them before proceeding."
|
||||
} else {
|
||||
enhanced = e.Content + "\n\n" + report
|
||||
if editedFiles == nil {
|
||||
editedFiles = make(map[string]bool)
|
||||
}
|
||||
editedFiles[absPath] = true
|
||||
return nil
|
||||
})
|
||||
|
||||
// After the agent turn ends, lint all collected files.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
if len(editedFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Show TUI message block for diagnostics visibility (only if there are issues)
|
||||
// Snapshot and reset immediately so the next turn starts clean.
|
||||
files := editedFiles
|
||||
editedFiles = nil
|
||||
|
||||
// Skip lint on errored turns.
|
||||
if e.StopReason == "error" {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect unique directories and file list for gopls.
|
||||
var allGoplsOutput []string
|
||||
for absPath := range files {
|
||||
res := runGopls(ctx.CWD, absPath)
|
||||
formatted := formatToolResult(res, "")
|
||||
if formatted != "" {
|
||||
allGoplsOutput = append(allGoplsOutput, fmt.Sprintf("# %s\n%s", filepath.Base(absPath), formatted))
|
||||
}
|
||||
}
|
||||
|
||||
lintRes := runGolangCILint(ctx.CWD, "./...")
|
||||
|
||||
goplsSection := "No diagnostics."
|
||||
if len(allGoplsOutput) > 0 {
|
||||
goplsSection = strings.Join(allGoplsOutput, "\n\n")
|
||||
}
|
||||
lintSection := formatToolResult(lintRes, "No lint issues.")
|
||||
|
||||
// Build file list for the report header.
|
||||
var fileNames []string
|
||||
for absPath := range files {
|
||||
fileNames = append(fileNames, filepath.Base(absPath))
|
||||
}
|
||||
|
||||
report := fmt.Sprintf(
|
||||
"<go_diagnostics files=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
|
||||
strings.Join(fileNames, ", "),
|
||||
goplsSection,
|
||||
lintSection,
|
||||
)
|
||||
|
||||
goplsIssues, lintIssues := countIssues(report)
|
||||
hasIssues := goplsIssues > 0 || lintIssues > 0
|
||||
|
||||
if hasIssues {
|
||||
// Show TUI block so the user sees it too.
|
||||
var msgLines []string
|
||||
msgLines = append(msgLines, fmt.Sprintf("File: %s", filepath.Base(absPath)))
|
||||
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
|
||||
if goplsIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
|
||||
}
|
||||
if lintIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
|
||||
}
|
||||
msgLines = append(msgLines, "", "⚠️ Please fix these issues before proceeding.")
|
||||
|
||||
borderColor := "#f9e2af" // yellow
|
||||
if goplsIssues > 0 && lintIssues > 0 {
|
||||
@@ -78,9 +124,16 @@ func Init(api ext.API) {
|
||||
BorderColor: borderColor,
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
}
|
||||
|
||||
return &ext.ToolResultResult{Content: &enhanced}
|
||||
// Inject a follow-up message so the agent fixes the issues.
|
||||
ctx.SendMessage(report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review and fix the issues above.")
|
||||
} else {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: fmt.Sprintf("Files: %s\n✓ All clean", strings.Join(fileNames, ", ")),
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,18 +159,6 @@ func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
|
||||
return absPath, true
|
||||
}
|
||||
|
||||
func runGoDiagnostics(cwd, absPath string) string {
|
||||
gopls := runGopls(cwd, absPath)
|
||||
lint := runGolangCILint(cwd, "./...")
|
||||
|
||||
return fmt.Sprintf(
|
||||
"<go_diagnostics file=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
|
||||
filepath.Base(absPath),
|
||||
formatToolResult(gopls, "No diagnostics."),
|
||||
formatToolResult(lint, "No lint issues."),
|
||||
)
|
||||
}
|
||||
|
||||
func runGopls(cwd, absPath string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
@@ -178,7 +219,9 @@ func formatToolResult(res lintResult, emptyFallback string) string {
|
||||
out := strings.TrimSpace(res.Output)
|
||||
if out == "" {
|
||||
if res.Err == nil {
|
||||
lines = append(lines, emptyFallback)
|
||||
if emptyFallback != "" {
|
||||
lines = append(lines, emptyFallback)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, out)
|
||||
@@ -197,17 +240,15 @@ func truncate(s string, max int) string {
|
||||
}
|
||||
|
||||
func countIssues(report string) (goplsCount, lintCount int) {
|
||||
// Extract gopls section
|
||||
goplsStart := strings.Index(report, "[gopls]")
|
||||
lintStart := strings.Index(report, "[golangci-lint]")
|
||||
endTag := strings.Index(report, "</go_diagnostics>")
|
||||
|
||||
if goplsStart != -1 && lintStart != -1 {
|
||||
goplsSection := report[goplsStart:lintStart]
|
||||
// Count non-empty lines excluding the header and "No diagnostics." message
|
||||
for _, line := range strings.Split(goplsSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[gopls]" && line != "No diagnostics." {
|
||||
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
|
||||
goplsCount++
|
||||
}
|
||||
}
|
||||
@@ -215,7 +256,6 @@ func countIssues(report string) (goplsCount, lintCount int) {
|
||||
|
||||
if lintStart != -1 && endTag != -1 {
|
||||
lintSection := report[lintStart:endTag]
|
||||
// Count non-empty lines excluding the header and "No lint issues." message
|
||||
for _, line := range strings.Split(lintSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
description: Create a feature request using the GitHub template
|
||||
---
|
||||
|
||||
Create a feature request for the Kit repository. The user wants to request: $@
|
||||
|
||||
## Feature Request Template
|
||||
|
||||
This prompt uses the `feature_request` GitHub template which requires:
|
||||
|
||||
| Field | Required | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **Feature Description** | Yes | What should be added or changed |
|
||||
| **Motivation / Use Case** | Yes | Why is this needed? What problem does it solve? |
|
||||
| **Proposed Implementation** | No | How do you think this should work? |
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Understand the request** from `$@`
|
||||
- What capability is missing?
|
||||
- What would the ideal behavior look like?
|
||||
|
||||
2. **Ask clarifying questions** if needed:
|
||||
- "What problem does this solve for you?"
|
||||
- "How would you expect this to work?"
|
||||
- "Are there similar features in other tools you use?"
|
||||
|
||||
3. **Craft the title** using conventional format:
|
||||
- `feat: <short description>`
|
||||
- Lowercase, imperative mood, ≤72 chars
|
||||
- Good examples:
|
||||
- `feat: add keyboard shortcut for clearing input`
|
||||
- `feat: support custom themes per extension`
|
||||
- `feat: add fuzzy matching to model selector`
|
||||
- Bad examples:
|
||||
- `Feature request: can we have...` (too vague)
|
||||
- `It would be nice if...` (not imperative)
|
||||
|
||||
4. **Build the body** with the template fields:
|
||||
|
||||
**Feature Description:**
|
||||
- Clear statement of what to add/change
|
||||
- Be specific about the behavior
|
||||
- Include UI/UX details if relevant
|
||||
|
||||
**Motivation / Use Case:**
|
||||
- What problem does this solve?
|
||||
- Current workaround (if any) and why it's insufficient
|
||||
- Who benefits from this feature?
|
||||
|
||||
**Proposed Implementation** (optional but helpful):
|
||||
- High-level approach
|
||||
- API changes if applicable
|
||||
- Example usage code
|
||||
|
||||
5. **Create the issue**:
|
||||
```bash
|
||||
gh issue create --template feature_request --title "feat: ..." --body "..."
|
||||
```
|
||||
|
||||
6. **Confirm success**:
|
||||
- Show the issue URL and number
|
||||
- Mention it was created with the feature_request template
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Focus on the *problem* first, then the solution
|
||||
- Include concrete examples of how the feature would be used
|
||||
- Consider edge cases and mention them
|
||||
- If proposing API changes, show before/after code
|
||||
- Check if similar features exist in related tools (mention them for reference)
|
||||
- Align with Kit's philosophy: TUI-first, extension-based, keyboard-driven
|
||||
|
||||
## Example
|
||||
|
||||
User: `/feature-request I want to be able to customize tool border colors dynamically`
|
||||
|
||||
You:
|
||||
1. Title: `feat: dynamic border colors for tool results based on status`
|
||||
2. Body:
|
||||
- **Feature Description**: Allow `ToolRenderConfig` to accept a function that determines border color based on tool result content or status, enabling dynamic visual feedback.
|
||||
- **Motivation**: When running multiple tools, it's hard to distinguish file reads (blue), shell commands (green), and errors (red) without custom colors per result.
|
||||
- **Proposed Implementation**: Add `BorderColorFunc` callback that receives `(result string, isError bool)` and returns a color string.
|
||||
|
||||
3. Execute: `gh issue create --template feature_request --title "feat: ..." --body "..."`
|
||||
4. Confirm: Created issue #43 using feature_request template
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
description: File a GitHub issue using the appropriate template
|
||||
---
|
||||
|
||||
File a GitHub issue for the Kit repository. The user wants to create an issue about: $@
|
||||
|
||||
## Issue Templates Available
|
||||
|
||||
This repository has structured issue templates. You MUST use the appropriate template:
|
||||
|
||||
| Type | Template | Use For |
|
||||
|------|----------|---------|
|
||||
| `bug` | `bug_report` | Something is broken, not working as expected |
|
||||
| `feat` | `feature_request` | New feature, enhancement, improvement |
|
||||
| `docs` | `documentation` | Missing, incorrect, or unclear documentation |
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Determine the issue type** from `$@`:
|
||||
- Bug → use `--template bug_report`
|
||||
- Feature → use `--template feature_request`
|
||||
- Documentation → use `--template documentation`
|
||||
|
||||
2. **Ask clarifying questions** if critical info is missing:
|
||||
- For bugs: "What were you doing when this happened?" (reproduction steps)
|
||||
- For features: "What problem does this solve?" (motivation)
|
||||
- For docs: "Where did you look for this information?" (location)
|
||||
|
||||
3. **Craft the title** using conventional format:
|
||||
- `<type>: <short description>`
|
||||
- Lowercase, imperative mood, ≤72 chars
|
||||
- Examples:
|
||||
- `fix: ToolRenderConfig BorderColor ignored during rendering`
|
||||
- `feat: add keyboard shortcut for clearing input`
|
||||
- `docs: clarify extension widget lifecycle`
|
||||
|
||||
4. **File the issue** using the template:
|
||||
```bash
|
||||
# For bugs
|
||||
gh issue create --template bug_report --title "fix: ..." --body "..."
|
||||
|
||||
# For features
|
||||
gh issue create --template feature_request --title "feat: ..." --body "..."
|
||||
|
||||
# For documentation
|
||||
gh issue create --template documentation --title "docs: ..." --body "..."
|
||||
```
|
||||
|
||||
The template will guide the user through the required fields. You need to provide:
|
||||
- **Bug reports**: Description, reproduction steps, expected vs actual behavior
|
||||
- **Feature requests**: Description, motivation/use case, optional proposed implementation
|
||||
- **Documentation**: Description, location of docs, suggested improvement
|
||||
|
||||
5. **Confirm success** by showing:
|
||||
- The issue URL
|
||||
- The issue number
|
||||
- Which template was used
|
||||
|
||||
## Template Field Guide
|
||||
|
||||
### Bug Report (`bug_report`)
|
||||
Required fields in the body:
|
||||
- **Bug Description** - what happened vs expected
|
||||
- **Steps to Reproduce** - numbered list to recreate the bug
|
||||
- **Relevant Code** - code snippets, configuration, error messages
|
||||
- **Component** - which part of Kit (ui, extensions, session, etc.)
|
||||
- **Version** - Kit version or commit hash
|
||||
|
||||
### Feature Request (`feature_request`)
|
||||
Required fields in the body:
|
||||
- **Feature Description** - what to add/change
|
||||
- **Motivation / Use Case** - why this is needed
|
||||
- **Proposed Implementation** - how it could work (optional)
|
||||
|
||||
### Documentation (`documentation`)
|
||||
Required fields in the body:
|
||||
- **Documentation Issue** - what's wrong or missing
|
||||
- **Documentation Location** - file or URL where docs exist
|
||||
- **Suggested Improvement** - how to fix the docs
|
||||
|
||||
## Guidelines
|
||||
|
||||
- ALWAYS use `--template <name>` instead of bare `gh issue create`
|
||||
- Include file paths and line numbers when you know them
|
||||
- Use triple backticks for code blocks
|
||||
- Keep the body factual - avoid speculation unless in "Proposed Fix" section
|
||||
- If you're unsure about technical details, say so in the issue
|
||||
- For UI bugs, describe what you see vs what you expect
|
||||
- For API bugs, include the relevant struct/function names
|
||||
|
||||
## Example Usage
|
||||
|
||||
User: `/file-issue The ToolRenderConfig BorderColor field is documented but never used in rendering`
|
||||
|
||||
You:
|
||||
1. Determine this is a **bug** (documented field doesn't work)
|
||||
2. Use `--template bug_report`
|
||||
3. Gather: reproduction steps (register renderer with BorderColor), expected (custom color), actual (default color)
|
||||
4. Create issue with title `fix: ToolRenderConfig BorderColor and Background fields are ignored`
|
||||
5. Confirm: Created issue #42 using bug_report template
|
||||
@@ -0,0 +1,80 @@
|
||||
# Autoscroll Fix - Final Summary
|
||||
|
||||
## Root Cause
|
||||
|
||||
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
|
||||
|
||||
### The Problem
|
||||
|
||||
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
|
||||
2. The `Height()` method returns `0` when `cachedRender == ""`
|
||||
3. `GotoBottom()` was calling:
|
||||
```go
|
||||
itemHeight := item.Height() // Returns 0 for reasoning
|
||||
if itemHeight == 0 {
|
||||
item.Render(s.width) // Renders but doesn't cache (reasoning)
|
||||
itemHeight = item.Height() // Still returns 0!
|
||||
}
|
||||
```
|
||||
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
|
||||
|
||||
## The Solution
|
||||
|
||||
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
|
||||
|
||||
```go
|
||||
// OLD: item.Height() which checks cached render
|
||||
itemHeight := item.Height()
|
||||
if itemHeight == 0 {
|
||||
item.Render(s.width)
|
||||
itemHeight = item.Height() // Still might be 0!
|
||||
}
|
||||
|
||||
// NEW: Calculate from rendered string directly
|
||||
rendered := item.Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
```
|
||||
|
||||
This works for **all** items regardless of whether they cache their render or not.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### `internal/ui/scrolllist.go`
|
||||
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
|
||||
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
|
||||
|
||||
### `internal/ui/model.go`
|
||||
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
|
||||
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
|
||||
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
|
||||
|
||||
## Testing Results
|
||||
|
||||
✅ **Test prompt**: "explore this repo"
|
||||
|
||||
**Before fix**:
|
||||
- Autoscroll stopped after reasoning block completed
|
||||
- Viewport stuck showing end of reasoning ("Thought for 203ms")
|
||||
- Assistant response streamed off-screen below
|
||||
|
||||
**After fix**:
|
||||
- Autoscroll works throughout reasoning block
|
||||
- Autoscroll continues during reasoning → assistant transition
|
||||
- Viewport stays at bottom showing latest assistant content
|
||||
- Final position shows end of response (build commands section)
|
||||
|
||||
## Behavior Verified
|
||||
|
||||
1. ✅ Streaming text auto-scrolls to bottom
|
||||
2. ✅ Works across reasoning → assistant transition
|
||||
3. ✅ Manual scroll up (PgUp) disables autoscroll
|
||||
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
|
||||
5. ✅ Accurate positioning with no offset errors
|
||||
|
||||
## Performance Note
|
||||
|
||||
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
|
||||
- `Render()` is already optimized with caching for non-reasoning items
|
||||
- `GotoBottom()` is only called during content updates (not every frame)
|
||||
- Reasoning blocks need to render anyway for live duration updates
|
||||
- This matches iteratr's approach of ensuring items are rendered before height calculations
|
||||
@@ -477,7 +477,7 @@ During an interactive session, use these slash commands:
|
||||
| `/import <path>` | Import and switch to a session from a JSONL file |
|
||||
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
|
||||
| `/tree` | Navigate the session tree |
|
||||
| `/fork` | Branch from an earlier message |
|
||||
| `/fork` | Fork to new session from an earlier message |
|
||||
| `/new` | Start a fresh session |
|
||||
|
||||
## Go SDK
|
||||
|
||||
+76
-80
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -217,29 +217,10 @@ func configToUiTheme(cfg config.Theme) ui.Theme {
|
||||
}
|
||||
}
|
||||
|
||||
// kitBanner returns the KIT ASCII art title with KITT scanner lights,
|
||||
// rendered with a KITT red gradient.
|
||||
// kitBanner returns the KIT ASCII art title with KITT scanner lights.
|
||||
// Delegates to ui.KitBanner() which owns the logo rendering.
|
||||
func kitBanner() string {
|
||||
kittDark := lipgloss.Color("#8B0000")
|
||||
kittBright := lipgloss.Color("#FF2200")
|
||||
lines := []string{
|
||||
" ██╗ ██╗ ██╗ ████████╗",
|
||||
" ██║ ██╔╝ ██║ ╚══██╔══╝",
|
||||
" █████╔╝ ██║ ██║",
|
||||
" ██╔═██╗ ██║ ██║",
|
||||
" ██║ ██╗ ██║ ██║",
|
||||
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
|
||||
" ░░░░░░▒▒▒▒▒▓▓▓▓███████████████▓▓▓▓▒▒▒▒▒░░░░░░",
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
result.WriteString(ui.ApplyGradient(line, kittDark, kittBright))
|
||||
}
|
||||
return result.String()
|
||||
return ui.KitBanner()
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -406,21 +387,21 @@ func runKit(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// extensionCommandsForUI converts extension-registered CommandDefs into the
|
||||
// ui.ExtensionCommand type used by the interactive TUI. Command names are
|
||||
// commands.ExtensionCommand type used by the interactive TUI. Command names are
|
||||
// normalised to start with "/" so they integrate with the slash-command
|
||||
// autocomplete and dispatch pipeline.
|
||||
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
|
||||
func extensionCommandsForUI(k *kit.Kit) []commands.ExtensionCommand {
|
||||
defs := k.Extensions().Commands()
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cmds := make([]ui.ExtensionCommand, 0, len(defs))
|
||||
cmds := make([]commands.ExtensionCommand, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
name := d.Name
|
||||
if len(name) > 0 && name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
ec := ui.ExtensionCommand{
|
||||
ec := commands.ExtensionCommand{
|
||||
Name: name,
|
||||
Description: d.Description,
|
||||
Execute: func(args string) (string, error) {
|
||||
@@ -1567,7 +1548,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
emitBeforeFork := beforeForkProviderForUI(kitInstance)
|
||||
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
|
||||
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
|
||||
getExtensionCommands := func() []ui.ExtensionCommand {
|
||||
getExtensionCommands := func() []commands.ExtensionCommand {
|
||||
return extensionCommandsForUI(kitInstance)
|
||||
}
|
||||
|
||||
@@ -1624,9 +1605,36 @@ func runNormalMode(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build extension reload callback for the /reload-ext command.
|
||||
reloadExtensionsForUI := func() error {
|
||||
err := kitInstance.Extensions().Reload()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start file watcher for automatic extension hot-reload.
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
watchDirs := extensions.WatchedDirs(extraPaths)
|
||||
if len(watchDirs) > 0 {
|
||||
extWatcher, watchErr := extensions.NewWatcher(watchDirs, func() {
|
||||
if err := reloadExtensionsForUI(); err != nil {
|
||||
log.Printf("auto-reload extensions failed: %v", err)
|
||||
}
|
||||
})
|
||||
if watchErr != nil {
|
||||
log.Printf("extension file watcher not started: %v", watchErr)
|
||||
} else {
|
||||
go extWatcher.Start(ctx)
|
||||
defer func() { _ = extWatcher.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -1634,7 +1642,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, startupExtensionMessages)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1647,7 +1655,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1690,7 +1698,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, nil)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1788,7 +1796,7 @@ func writeJSONError(err error) {
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -1799,55 +1807,43 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Cwd: cwd,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
SetModel: setModel,
|
||||
EmitModelChange: emitModelChange,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
SwitchSession: switchSession,
|
||||
ShowSessionPicker: resumeFlag,
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Cwd: cwd,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
StartupExtensionMessages: startupExtensionMessages,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
SetModel: setModel,
|
||||
EmitModelChange: emitModelChange,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
SwitchSession: switchSession,
|
||||
ReloadExtensions: reloadExtensions,
|
||||
ShowSessionPicker: resumeFlag,
|
||||
})
|
||||
|
||||
// Print KIT banner and startup info to stdout before Bubble Tea takes over the screen.
|
||||
fmt.Println(kitBanner())
|
||||
fmt.Println()
|
||||
appModel.PrintStartupInfo()
|
||||
|
||||
// Print any extension messages that were captured during startup.
|
||||
if len(startupExtensionMessages) > 0 {
|
||||
fmt.Println()
|
||||
for _, msg := range startupExtensionMessages {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
program := tea.NewProgram(appModel)
|
||||
|
||||
// Register the program with the app layer so agent events are sent to the TUI.
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
// development: edit your extension source, then type /reload to pick up
|
||||
// changes immediately.
|
||||
//
|
||||
// Event handlers, slash commands, tool renderers, message renderers, and
|
||||
// keyboard shortcuts update immediately. Extension-defined tools are NOT
|
||||
// updated (they are baked into the agent at creation time and require a
|
||||
// restart).
|
||||
// Note: Extensions in autoloaded directories (~/.config/kit/extensions/
|
||||
// and .kit/extensions/) are automatically reloaded on save. The /reload
|
||||
// command is useful for extensions loaded via -e from other locations.
|
||||
//
|
||||
// Event handlers, slash commands, tool definitions, tool renderers,
|
||||
// message renderers, and keyboard shortcuts all update immediately.
|
||||
//
|
||||
// Commands:
|
||||
// /reload — hot-reload all extensions from disk
|
||||
|
||||
@@ -9,12 +9,14 @@ require (
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
|
||||
github.com/coder/acp-go-sdk v0.6.3
|
||||
github.com/indaco/herald v0.10.0
|
||||
github.com/indaco/herald-md v0.1.0
|
||||
github.com/indaco/herald v0.11.0
|
||||
github.com/indaco/herald-md v0.2.0
|
||||
github.com/mark3labs/mcp-go v0.46.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
@@ -30,7 +32,6 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
|
||||
@@ -52,7 +53,6 @@ require (
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 // indirect
|
||||
@@ -115,10 +115,10 @@ require (
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.273.0 // indirect
|
||||
google.golang.org/genai v1.52.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/api v0.273.1 // indirect
|
||||
google.golang.org/genai v1.52.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -181,10 +181,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/indaco/herald v0.10.0 h1:XzahEKX6cr50qZQrUdA3QrQBHg8uGm5jETD0UDi21BI=
|
||||
github.com/indaco/herald v0.10.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
|
||||
github.com/indaco/herald-md v0.1.0 h1:zmYudYo+uamzKTBcIffJVJYrqk9xDNnVrTh+de2zciw=
|
||||
github.com/indaco/herald-md v0.1.0/go.mod h1:Z1HxPCbSn+/+TFzOM/UbsmKeEk/28NNI6JOTileKXto=
|
||||
github.com/indaco/herald v0.11.0 h1:tJZc6DAzfUYVWQsU9Lik4RcKR7TtiRfnBIu/oXjp/WA=
|
||||
github.com/indaco/herald v0.11.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
|
||||
github.com/indaco/herald-md v0.2.0 h1:kGFsKE+Swzf7EyTUFx7FL1d1jwiKoJRcxqYo2bhUgS0=
|
||||
github.com/indaco/herald-md v0.2.0/go.mod h1:64DKh1wSQUsWXTuIYklFzSheJKkW0+FpaqyKqwids3g=
|
||||
github.com/kaptinlin/go-i18n v0.3.0 h1:wP76dvYg04bvwTb+8NB+CmdZ2kL7lSSCQ9B/kFv7QHo=
|
||||
github.com/kaptinlin/go-i18n v0.3.0/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
|
||||
@@ -307,20 +307,20 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
|
||||
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
|
||||
google.golang.org/genai v1.52.0 h1:ekVIxWHtLUNbt+v0WWi4j3JT4yrHDEbysMcHQcaCQoI=
|
||||
google.golang.org/genai v1.52.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY=
|
||||
google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
|
||||
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
|
||||
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+54
-1
@@ -63,6 +63,10 @@ type ToolCallContentHandler func(content string)
|
||||
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
|
||||
type ReasoningDeltaHandler func(delta string)
|
||||
|
||||
// ReasoningCompleteHandler is a function type for handling reasoning/thinking completion.
|
||||
// Called when the last reasoning token has been processed, before text streaming starts.
|
||||
type ReasoningCompleteHandler func()
|
||||
|
||||
// ToolOutputHandler is a function type for handling streaming tool output chunks.
|
||||
// Used by tools like bash to stream output as it arrives rather than waiting
|
||||
// for the command to complete. The isStderr flag indicates if the chunk
|
||||
@@ -231,7 +235,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil)
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
@@ -242,6 +246,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onReasoningComplete ReasoningCompleteHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
@@ -295,6 +300,17 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
return nil
|
||||
},
|
||||
|
||||
// Reasoning/thinking complete callback
|
||||
OnReasoningEnd: func(id string, _ fantasy.ReasoningContent) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningComplete != nil {
|
||||
onReasoningComplete()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Text streaming callback
|
||||
OnTextDelta: func(id, text string) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -623,6 +639,43 @@ func (a *Agent) GetExtensionToolCount() int {
|
||||
return len(a.extraTools)
|
||||
}
|
||||
|
||||
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
|
||||
// tools) and rebuilds the internal agent with the updated tool list. The
|
||||
// model, system prompt, and all other configuration are preserved.
|
||||
func (a *Agent) SetExtraTools(tools []fantasy.AgentTool) {
|
||||
a.extraTools = tools
|
||||
|
||||
// Rebuild tool list (same as NewAgent / SetModel).
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
}
|
||||
if a.toolWrapper != nil {
|
||||
allTools = a.toolWrapper(allTools)
|
||||
}
|
||||
|
||||
// Rebuild agent options with the existing model.
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if a.systemPrompt != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
|
||||
}
|
||||
if len(allTools) > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
|
||||
}
|
||||
if a.maxSteps > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithStopConditions(
|
||||
fantasy.StepCountIs(a.maxSteps),
|
||||
))
|
||||
}
|
||||
|
||||
// Swap the fantasy agent (model and provider are unchanged).
|
||||
a.fantasyAgent = fantasy.NewAgent(a.model, agentOpts...)
|
||||
}
|
||||
|
||||
// GetLoadingMessage returns the loading message from provider creation.
|
||||
func (a *Agent) GetLoadingMessage() string {
|
||||
return a.loadingMessage
|
||||
|
||||
@@ -788,6 +788,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ReasoningDeltaEvent:
|
||||
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
|
||||
case kit.ReasoningCompleteEvent:
|
||||
sendFn(ReasoningCompleteEvent{})
|
||||
case kit.ToolOutputEvent:
|
||||
sendFn(ToolOutputEvent{
|
||||
ToolCallID: ev.ToolCallID,
|
||||
|
||||
@@ -16,6 +16,11 @@ type ReasoningChunkEvent struct {
|
||||
Delta string
|
||||
}
|
||||
|
||||
// ReasoningCompleteEvent is sent when reasoning/thinking is finished, after
|
||||
// the last reasoning token has been processed. The TUI uses this to freeze
|
||||
// the reasoning duration counter.
|
||||
type ReasoningCompleteEvent struct{}
|
||||
|
||||
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
|
||||
// It carries the tool name and its arguments for display purposes.
|
||||
type ToolCallStartedEvent struct {
|
||||
|
||||
+1
-20
@@ -67,7 +67,7 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return readDirectory(absPath)
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("'%s' is a directory, not a file. Use the ls tool to list directory contents.", args.Path)), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
@@ -116,25 +116,6 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
return fantasy.NewTextResponse(tr.Content), nil
|
||||
}
|
||||
|
||||
func readDirectory(absPath string) (fantasy.ToolResponse, error) {
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
result.WriteString(name + "\n")
|
||||
}
|
||||
|
||||
tr := truncateHead(result.String(), 500, defaultMaxBytes)
|
||||
return fantasy.NewTextResponse(tr.Content), nil
|
||||
}
|
||||
|
||||
// resolvePathWithWorkDir resolves a path to an absolute path relative to the
|
||||
// given workDir. If workDir is empty, os.Getwd() is used.
|
||||
func resolvePathWithWorkDir(path, workDir string) (string, error) {
|
||||
|
||||
+26
-33
@@ -130,13 +130,22 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
|
||||
), fmt.Errorf("no subagent spawner in context")
|
||||
}
|
||||
|
||||
// Detach from the parent's deadline so the subagent gets its own
|
||||
// independent timeout (applied downstream in Kit.Subagent). The parent
|
||||
// context may carry a tight deadline from the LLM generation loop or
|
||||
// other tool timeouts that would prematurely kill the subagent.
|
||||
// We preserve context values (spawner, etc.) and propagate parent
|
||||
// cancellation (e.g. user hits Ctrl-C) without inheriting the deadline.
|
||||
spawnCtx := detachedWithCancel(ctx)
|
||||
// Build a clean context for the subagent that inherits values (e.g. the
|
||||
// spawner callback) but is completely detached from the parent's
|
||||
// deadline AND cancellation. The subagent gets its own independent
|
||||
// timeout (applied downstream in Kit.Subagent).
|
||||
//
|
||||
// Why full detachment instead of propagating parent cancellation?
|
||||
// The parent context may already be done (deadline exceeded or
|
||||
// cancelled) by the time this tool handler executes — for example when
|
||||
// the generation loop context carries a deadline, when the user
|
||||
// double-ESC cancels mid-turn, or when parallel tool execution
|
||||
// encounters a race between stream completion and tool dispatch. Using
|
||||
// context.WithoutCancel (Go 1.21+) ensures the subagent always starts
|
||||
// cleanly with a fresh timeout, following the pattern used by crush for
|
||||
// shutdown-resilient child work. The subagent's own timeout
|
||||
// (defaultSubagentTimeout / user-specified) provides the safety net.
|
||||
spawnCtx := context.WithoutCancel(valuesContext{parent: ctx})
|
||||
|
||||
// Spawn in-process subagent.
|
||||
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
@@ -173,37 +182,21 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context detachment
|
||||
// Context helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// detachedContext wraps a parent context, preserving its values but removing
|
||||
// its deadline and cancellation. This allows the subagent to have its own
|
||||
// independent timeout while still accessing context-stored values (e.g. the
|
||||
// subagent spawner function).
|
||||
type detachedContext struct {
|
||||
// valuesContext preserves a parent context's values (e.g. the subagent
|
||||
// spawner callback) while stripping its deadline and cancellation. Combined
|
||||
// with context.WithoutCancel() this gives the subagent a completely clean
|
||||
// context that only inherits value-based dependencies.
|
||||
type valuesContext struct {
|
||||
parent context.Context
|
||||
}
|
||||
|
||||
func (d detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (d detachedContext) Done() <-chan struct{} { return nil }
|
||||
func (d detachedContext) Err() error { return nil }
|
||||
func (d detachedContext) Value(key any) any { return d.parent.Value(key) }
|
||||
|
||||
// detachedWithCancel creates a new context that inherits values from the
|
||||
// parent but has no deadline. Cancellation of the parent is propagated: when
|
||||
// the parent is cancelled the returned context is also cancelled, but the
|
||||
// parent's deadline does not apply to the child.
|
||||
func detachedWithCancel(parent context.Context) context.Context {
|
||||
child, cancel := context.WithCancel(detachedContext{parent: parent})
|
||||
go func() {
|
||||
select {
|
||||
case <-parent.Done():
|
||||
cancel()
|
||||
case <-child.Done():
|
||||
}
|
||||
}()
|
||||
return child
|
||||
}
|
||||
func (v valuesContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (v valuesContext) Done() <-chan struct{} { return nil }
|
||||
func (v valuesContext) Err() error { return nil }
|
||||
func (v valuesContext) Value(key any) any { return v.parent.Value(key) }
|
||||
|
||||
// truncateResponse limits the response length to avoid overwhelming context windows.
|
||||
func truncateResponse(s string, maxLen int) string {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) {
|
||||
// Parent with a tight deadline.
|
||||
parent, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
time.Sleep(5 * time.Millisecond) // Let deadline expire.
|
||||
|
||||
if parent.Err() == nil {
|
||||
t.Fatal("expected parent to be expired")
|
||||
}
|
||||
|
||||
vc := valuesContext{parent: parent}
|
||||
|
||||
if _, ok := vc.Deadline(); ok {
|
||||
t.Error("valuesContext should report no deadline")
|
||||
}
|
||||
if vc.Done() != nil {
|
||||
t.Error("valuesContext.Done() should return nil")
|
||||
}
|
||||
if vc.Err() != nil {
|
||||
t.Errorf("valuesContext.Err() should be nil, got %v", vc.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValuesContext_PreservesValues(t *testing.T) {
|
||||
type testKey struct{}
|
||||
parent := context.WithValue(context.Background(), testKey{}, "hello")
|
||||
|
||||
vc := valuesContext{parent: parent}
|
||||
|
||||
got, ok := vc.Value(testKey{}).(string)
|
||||
if !ok || got != "hello" {
|
||||
t.Errorf("expected value 'hello', got %q (ok=%v)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnContext_SurvivesCancelledParent(t *testing.T) {
|
||||
// Simulate the exact scenario from the bug: the parent generation
|
||||
// context is already cancelled when the subagent tool handler runs.
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancelled before detach.
|
||||
|
||||
// This is what executeSubagent now does:
|
||||
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
|
||||
|
||||
// The spawn context must be alive.
|
||||
if spawnCtx.Err() != nil {
|
||||
t.Fatalf("spawnCtx should be alive, got err: %v", spawnCtx.Err())
|
||||
}
|
||||
|
||||
// Adding a timeout should produce a working context.
|
||||
tCtx, tCancel := context.WithTimeout(spawnCtx, 5*time.Second)
|
||||
defer tCancel()
|
||||
|
||||
if tCtx.Err() != nil {
|
||||
t.Fatalf("timeout context should be alive, got err: %v", tCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnContext_SurvivesDeadlineExceededParent(t *testing.T) {
|
||||
// Simulate: parent had a deadline that already expired.
|
||||
parent, pCancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer pCancel()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
if parent.Err() != context.DeadlineExceeded {
|
||||
t.Fatalf("expected parent deadline exceeded, got: %v", parent.Err())
|
||||
}
|
||||
|
||||
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
|
||||
|
||||
if spawnCtx.Err() != nil {
|
||||
t.Fatalf("spawnCtx should be alive after deadline-exceeded parent, got: %v", spawnCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnContext_PreservesSpawnerValue(t *testing.T) {
|
||||
// Verify the subagent spawner callback survives context detachment.
|
||||
called := false
|
||||
spawner := SubagentSpawnFunc(func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) {
|
||||
called = true
|
||||
return &SubagentSpawnResult{Response: "ok"}, nil
|
||||
})
|
||||
|
||||
parent := WithSubagentSpawner(context.Background(), spawner)
|
||||
// Cancel the parent.
|
||||
parentCtx, cancel := context.WithCancel(parent)
|
||||
cancel()
|
||||
|
||||
spawnCtx := context.WithoutCancel(valuesContext{parent: parentCtx})
|
||||
|
||||
// Should be able to retrieve the spawner from the detached context.
|
||||
recovered := getSubagentSpawner(spawnCtx)
|
||||
if recovered == nil {
|
||||
t.Fatal("spawner should be recoverable from detached context")
|
||||
}
|
||||
|
||||
result, err := recovered(spawnCtx, "tc1", "test task", "", "", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("spawner call failed: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("spawner was not called")
|
||||
}
|
||||
if result.Response != "ok" {
|
||||
t.Errorf("expected 'ok', got %q", result.Response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// Watcher monitors extension directories for file changes and triggers
|
||||
// a reload callback when .go files are created, modified, or removed.
|
||||
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
|
||||
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
onReload func()
|
||||
debounce time.Duration
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewWatcher creates a file watcher that monitors the given directories
|
||||
// for .go file changes. When a change is detected (after debouncing),
|
||||
// onReload is called. The watcher must be started with Start() and
|
||||
// stopped with Close().
|
||||
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// Watch the directory itself.
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
log.Debug("watcher: skipping directory", "dir", dir, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also watch immediate subdirectories (for */main.go pattern).
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(dir, entry.Name())
|
||||
if err := fsw.Add(subdir); err != nil {
|
||||
log.Debug("watcher: skipping subdirectory", "dir", subdir, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
watcher: fsw,
|
||||
onReload: onReload,
|
||||
debounce: 300 * time.Millisecond,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes. It blocks until the context
|
||||
// is cancelled or Close() is called. Typically called in a goroutine.
|
||||
func (w *Watcher) Start(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
ctx, w.cancel = context.WithCancel(ctx)
|
||||
w.mu.Unlock()
|
||||
|
||||
defer close(w.done)
|
||||
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Only care about .go files.
|
||||
if !strings.HasSuffix(event.Name, ".go") {
|
||||
continue
|
||||
}
|
||||
|
||||
// React to write, create, remove, rename events.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("watcher: file changed", "file", event.Name, "op", event.Op)
|
||||
|
||||
// Debounce: reset timer on each event.
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(w.debounce)
|
||||
timerC = timer.C
|
||||
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
timer = nil
|
||||
log.Debug("watcher: reloading extensions")
|
||||
w.onReload()
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Warn("watcher: error", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watcher and releases resources.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Wait for the event loop to finish.
|
||||
<-w.done
|
||||
return w.watcher.Close()
|
||||
}
|
||||
|
||||
// WatchedDirs returns the directories to watch for extension changes.
|
||||
// This includes the global extensions directory and the project-local
|
||||
// .kit/extensions/ directory (if they exist). Explicit -e paths that
|
||||
// point to directories are also included; explicit file paths cause
|
||||
// their parent directory to be watched instead.
|
||||
func WatchedDirs(extraPaths []string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(dir string) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the directory exists.
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
|
||||
// Global extensions dir.
|
||||
add(globalExtensionsDir())
|
||||
|
||||
// Project-local extensions dir.
|
||||
add(filepath.Join(".kit", "extensions"))
|
||||
|
||||
// Explicit paths that are directories.
|
||||
for _, p := range extraPaths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(p)
|
||||
} else {
|
||||
// For explicit files, watch the parent directory.
|
||||
add(filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatcher_ReloadsOnGoFileChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write an initial extension file.
|
||||
extFile := filepath.Join(dir, "test.go")
|
||||
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
|
||||
w, err := NewWatcher([]string{dir}, func() {
|
||||
reloadCount.Add(1)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
// Modify the file.
|
||||
time.Sleep(50 * time.Millisecond) // let watcher settle
|
||||
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for debounce (300ms) + margin.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 reload, got %d", got)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_IgnoresNonGoFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
|
||||
w, err := NewWatcher([]string{dir}, func() {
|
||||
reloadCount.Add(1)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
// Write a non-.go file.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
txtFile := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(txtFile, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait past the debounce window.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 0 {
|
||||
t.Errorf("expected 0 reloads for .txt file, got %d", got)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_Debounces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
extFile := filepath.Join(dir, "ext.go")
|
||||
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
|
||||
w, err := NewWatcher([]string{dir}, func() {
|
||||
reloadCount.Add(1)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Rapid-fire writes (simulating editor save: write temp, rename, etc.).
|
||||
for range 5 {
|
||||
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Wait for debounce to fire.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 debounced reload, got %d", got)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchedDirs_Deduplicates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dirs := WatchedDirs([]string{dir, dir})
|
||||
|
||||
count := 0
|
||||
for _, d := range dirs {
|
||||
abs, _ := filepath.Abs(dir)
|
||||
if d == abs {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected directory to appear once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchedDirs_FileParent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "ext.go")
|
||||
if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dirs := WatchedDirs([]string{file})
|
||||
|
||||
abs, _ := filepath.Abs(dir)
|
||||
found := false
|
||||
for _, d := range dirs {
|
||||
if d == abs {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected parent dir %s in watched dirs %v", abs, dirs)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,24 @@ type AgentSetupOptions struct {
|
||||
// wrapping. Used by the SDK hook system. Both wrappers compose:
|
||||
// extension wrapper runs first (inner), then this wrapper (outer).
|
||||
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
|
||||
|
||||
// ProviderConfig, when non-nil, is used directly instead of calling
|
||||
// BuildProviderConfig(). Callers that already hold viperInitMu can
|
||||
// pre-build this and release the lock before calling SetupAgent, so the
|
||||
// slow agent/MCP initialisation runs concurrently with other New() calls.
|
||||
ProviderConfig *models.ProviderConfig
|
||||
// Debug enables debug logging. When zero-value, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
Debug bool
|
||||
// NoExtensions skips extension loading. When false, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
NoExtensions bool
|
||||
// MaxSteps overrides the agent step limit. 0 means use viper value.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
MaxSteps int
|
||||
// StreamingEnabled controls streaming. Only meaningful when ProviderConfig
|
||||
// is also set.
|
||||
StreamingEnabled bool
|
||||
}
|
||||
|
||||
// AgentSetupResult bundles the created agent and any debug logger so the caller
|
||||
@@ -88,15 +106,36 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
// SetupAgent creates an agent from the current viper state + the provided
|
||||
// options. It wraps BuildProviderConfig and agent.CreateAgent.
|
||||
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
|
||||
modelConfig, systemPrompt, err := BuildProviderConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var modelConfig *models.ProviderConfig
|
||||
var systemPrompt string
|
||||
|
||||
if opts.ProviderConfig != nil {
|
||||
// Pre-built config supplied by caller (e.g. Kit.New after releasing
|
||||
// viperInitMu). Use it directly — no viper reads needed here.
|
||||
modelConfig = opts.ProviderConfig
|
||||
systemPrompt = modelConfig.SystemPrompt
|
||||
} else {
|
||||
var err error
|
||||
modelConfig, systemPrompt, err = BuildProviderConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve debug / no-extensions / max-steps / streaming: prefer explicit
|
||||
// fields (set when ProviderConfig was pre-built) over viper fallback.
|
||||
debugEnabled := opts.Debug || viper.GetBool("debug")
|
||||
noExtensions := opts.NoExtensions || viper.GetBool("no-extensions")
|
||||
maxSteps := opts.MaxSteps
|
||||
if maxSteps == 0 {
|
||||
maxSteps = viper.GetInt("max-steps")
|
||||
}
|
||||
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
|
||||
|
||||
// Create the appropriate debug logger.
|
||||
var debugLogger tools.DebugLogger
|
||||
var bufferedLogger *tools.BufferedDebugLogger
|
||||
if viper.GetBool("debug") {
|
||||
if debugEnabled {
|
||||
if opts.UseBufferedLogger {
|
||||
bufferedLogger = tools.NewBufferedDebugLogger(true)
|
||||
debugLogger = bufferedLogger
|
||||
@@ -108,7 +147,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
// Load extensions unless --no-extensions is set.
|
||||
var extRunner *extensions.Runner
|
||||
var extCreationOpts extensionCreationOpts
|
||||
if !viper.GetBool("no-extensions") {
|
||||
if !noExtensions {
|
||||
var extErr error
|
||||
extRunner, extCreationOpts, extErr = loadExtensions()
|
||||
if extErr != nil {
|
||||
@@ -140,8 +179,8 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
ModelConfig: modelConfig,
|
||||
MCPConfig: opts.MCPConfig,
|
||||
SystemPrompt: systemPrompt,
|
||||
MaxSteps: viper.GetInt("max-steps"),
|
||||
StreamingEnabled: viper.GetBool("stream"),
|
||||
MaxSteps: maxSteps,
|
||||
StreamingEnabled: streamingEnabled,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
Quiet: opts.Quiet,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
|
||||
@@ -400,6 +400,52 @@ func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
|
||||
return &info
|
||||
}
|
||||
|
||||
// ValidateModelString checks whether a model string is well-formed and refers
|
||||
// to a known provider. It returns a user-friendly error with suggestions when
|
||||
// the model or provider is unrecognised. Passing validation does not guarantee
|
||||
// that API authentication will succeed — it only catches obvious mistakes
|
||||
// (typos, missing provider prefix, non-existent provider names) early so that
|
||||
// callers such as subagent spawning can return fast feedback.
|
||||
//
|
||||
// Unknown models under a known provider are allowed (the provider API is the
|
||||
// authority), but a completely unknown provider is rejected.
|
||||
func (r *ModelsRegistry) ValidateModelString(modelString string) error {
|
||||
provider, modelName, err := ParseModelString(modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ollama and custom are always valid — model names are user-defined.
|
||||
if provider == "ollama" || provider == "custom" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the provider exists in the registry.
|
||||
providerInfo := r.GetProviderInfo(provider)
|
||||
if providerInfo == nil {
|
||||
known := r.GetSupportedProviders()
|
||||
return fmt.Errorf(
|
||||
"unknown provider %q in model string %q. Known providers: %s",
|
||||
provider, modelString, strings.Join(known, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
// Provider exists — check if the model is known. An unknown model is
|
||||
// only a warning (the provider API decides), but we surface suggestions
|
||||
// so the caller can self-correct.
|
||||
if r.LookupModel(provider, modelName) == nil {
|
||||
if suggestions := r.SuggestModels(provider, modelName); len(suggestions) > 0 {
|
||||
return fmt.Errorf(
|
||||
"model %q not found for provider %s. Did you mean one of: %s",
|
||||
modelName, provider, strings.Join(suggestions, ", "),
|
||||
)
|
||||
}
|
||||
// No suggestions — let it through; the provider API is the authority.
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Global registry instance
|
||||
var globalRegistry = NewModelsRegistry()
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateModelString(t *testing.T) {
|
||||
registry := GetGlobalRegistry()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
wantErr bool
|
||||
errSubstr string // expected substring in error message (empty = don't check)
|
||||
}{
|
||||
{
|
||||
name: "valid anthropic model",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing provider prefix",
|
||||
model: "claude-sonnet-4-6",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model format",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
model: "",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model format",
|
||||
},
|
||||
{
|
||||
name: "unknown provider",
|
||||
model: "fakeprovider/some-model",
|
||||
wantErr: true,
|
||||
errSubstr: "unknown provider",
|
||||
},
|
||||
{
|
||||
name: "ollama always valid",
|
||||
model: "ollama/llama3",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "custom always valid",
|
||||
model: "custom/my-fine-tune",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty provider",
|
||||
model: "/claude-sonnet-4-6",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model format",
|
||||
},
|
||||
{
|
||||
name: "empty model name",
|
||||
model: "anthropic/",
|
||||
wantErr: true,
|
||||
errSubstr: "invalid model format",
|
||||
},
|
||||
{
|
||||
name: "unknown model under known provider (no suggestions)",
|
||||
model: "anthropic/totally-unknown-xyz-999",
|
||||
wantErr: false, // no suggestions → passes through
|
||||
},
|
||||
{
|
||||
name: "typo model under known provider with suggestions",
|
||||
model: "anthropic/claude-sonet", // misspelled "sonnet"
|
||||
wantErr: true,
|
||||
errSubstr: "Did you mean",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := registry.ValidateModelString(tt.model)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Errorf("ValidateModelString(%q) = nil, want error", tt.model)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("ValidateModelString(%q) = %v, want nil", tt.model, err)
|
||||
}
|
||||
if tt.errSubstr != "" && err != nil {
|
||||
if !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Errorf("ValidateModelString(%q) error = %q, want substring %q",
|
||||
tt.model, err.Error(), tt.errSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,187 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// ForkToNewSession creates a new session file containing the history up to and
|
||||
// including the target entry ID. This matches Pi's /fork behavior: it creates
|
||||
// a completely new session file with a parent_session reference, copying all
|
||||
// entries from the root to the target point.
|
||||
func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManager, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
// Get the branch from root to target (root-to-leaf order).
|
||||
branch := tm.getBranchLocked(targetID)
|
||||
if len(branch) == 0 {
|
||||
return nil, fmt.Errorf("target entry %q not found", targetID)
|
||||
}
|
||||
|
||||
// Create a new session file.
|
||||
newTm, err := CreateTreeSession(cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the parent session reference in the header.
|
||||
newTm.header.ParentSession = tm.filePath
|
||||
newTm.header.ParentSessionID = tm.header.ID
|
||||
|
||||
// Rewrite the header with the parent reference.
|
||||
// We need to close and recreate the file to rewrite the header.
|
||||
if err := newTm.file.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close new session file: %w", err)
|
||||
}
|
||||
|
||||
// Recreate the file and write the updated header.
|
||||
f, err := os.Create(newTm.filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to recreate session file: %w", err)
|
||||
}
|
||||
newTm.file = f
|
||||
|
||||
if err := newTm.writeEntry(&newTm.header); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("failed to write session header: %w", err)
|
||||
}
|
||||
|
||||
// Copy entries from the branch to the new session.
|
||||
// We need to remap IDs since the new session is independent.
|
||||
idMap := make(map[string]string) // old ID -> new ID
|
||||
var prevNewID string
|
||||
|
||||
for _, entry := range branch {
|
||||
oldID := tm.EntryID(entry)
|
||||
newID := GenerateEntryID()
|
||||
idMap[oldID] = newID
|
||||
|
||||
// Create a copy of the entry with the new ID and remapped parent.
|
||||
var newEntry any
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
newEntry = &MessageEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeMessage,
|
||||
ID: newID,
|
||||
ParentID: prevNewID, // Chain sequentially in new session
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
Role: e.Role,
|
||||
Parts: e.Parts,
|
||||
Model: e.Model,
|
||||
Provider: e.Provider,
|
||||
}
|
||||
// Copy label if present.
|
||||
if label, ok := tm.labels[oldID]; ok {
|
||||
newTm.labels[newID] = label
|
||||
}
|
||||
|
||||
case *ModelChangeEntry:
|
||||
newEntry = &ModelChangeEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeModelChange,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
Provider: e.Provider,
|
||||
ModelID: e.ModelID,
|
||||
}
|
||||
|
||||
case *LabelEntry:
|
||||
// Remap the target ID if it's in our copied branch.
|
||||
newTargetID := e.TargetID
|
||||
if mapped, ok := idMap[e.TargetID]; ok {
|
||||
newTargetID = mapped
|
||||
}
|
||||
newEntry = &LabelEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeLabel,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
TargetID: newTargetID,
|
||||
Label: e.Label,
|
||||
}
|
||||
|
||||
case *SessionInfoEntry:
|
||||
newEntry = &SessionInfoEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeSessionInfo,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
Name: e.Name,
|
||||
}
|
||||
newTm.sessionName = e.Name
|
||||
|
||||
case *ExtensionDataEntry:
|
||||
newEntry = &ExtensionDataEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeExtensionData,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
ExtType: e.ExtType,
|
||||
Data: e.Data,
|
||||
}
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
// Remap the from ID if it's in our copied branch.
|
||||
newFromID := e.FromID
|
||||
if mapped, ok := idMap[e.FromID]; ok {
|
||||
newFromID = mapped
|
||||
}
|
||||
newEntry = &BranchSummaryEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeBranchSummary,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
FromID: newFromID,
|
||||
Summary: e.Summary,
|
||||
}
|
||||
|
||||
case *CompactionEntry:
|
||||
// Remap the first kept entry ID if it's in our copied branch.
|
||||
newFirstKeptID := e.FirstKeptEntryID
|
||||
if mapped, ok := idMap[e.FirstKeptEntryID]; ok {
|
||||
newFirstKeptID = mapped
|
||||
}
|
||||
newEntry = &CompactionEntry{
|
||||
Entry: Entry{
|
||||
Type: EntryTypeCompaction,
|
||||
ID: newID,
|
||||
ParentID: prevNewID,
|
||||
Timestamp: e.Timestamp,
|
||||
},
|
||||
Summary: e.Summary,
|
||||
FirstKeptEntryID: newFirstKeptID,
|
||||
TokensBefore: e.TokensBefore,
|
||||
TokensAfter: e.TokensAfter,
|
||||
MessagesRemoved: e.MessagesRemoved,
|
||||
ReadFiles: e.ReadFiles,
|
||||
ModifiedFiles: e.ModifiedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
if newEntry != nil {
|
||||
if err := newTm.appendAndPersist(newEntry); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("failed to copy entry: %w", err)
|
||||
}
|
||||
prevNewID = newID
|
||||
}
|
||||
}
|
||||
|
||||
// Set the leaf to the last entry in the new session.
|
||||
newTm.leafID = prevNewID
|
||||
|
||||
return newTm, nil
|
||||
}
|
||||
|
||||
// OpenTreeSession opens an existing JSONL session file.
|
||||
func OpenTreeSession(path string) (*TreeManager, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// blockRenderer handles rendering of content blocks with configurable options
|
||||
@@ -175,7 +177,7 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
|
||||
borderChars = 1
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
// Resolve foreground color: caller override or theme default.
|
||||
fgColor := theme.Text
|
||||
|
||||
+19
-150
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
@@ -59,7 +60,7 @@ func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) {
|
||||
t.Fatal("expected a cmd from pressing enter on non-empty input")
|
||||
}
|
||||
|
||||
sm, ok := msg.(submitMsg)
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected submitMsg, got %T", msg)
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestInputComponent_CtrlD_SubmitEmitsSubmitMsg(t *testing.T) {
|
||||
if msg == nil {
|
||||
t.Fatal("expected a cmd from ctrl+d on non-empty input")
|
||||
}
|
||||
sm, ok := msg.(submitMsg)
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected submitMsg from ctrl+d, got %T", msg)
|
||||
}
|
||||
@@ -175,7 +176,7 @@ func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
|
||||
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
|
||||
}
|
||||
msg := runCmd(cmd)
|
||||
sm, ok := msg.(submitMsg)
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
|
||||
}
|
||||
@@ -230,7 +231,7 @@ func TestInputComponent_ClearQueue_ForwardsAsSubmitMsg(t *testing.T) {
|
||||
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
|
||||
}
|
||||
msg := runCmd(cmd)
|
||||
sm, ok := msg.(submitMsg)
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
|
||||
}
|
||||
@@ -258,7 +259,7 @@ func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
|
||||
if msg == nil {
|
||||
t.Fatal("expected submitMsg for unknown slash command")
|
||||
}
|
||||
sm, ok := msg.(submitMsg)
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected submitMsg for unknown slash command, got %T", msg)
|
||||
}
|
||||
@@ -701,167 +702,35 @@ func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
|
||||
|
||||
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
|
||||
// unconstrained (0), ConsumeOverflow always returns "".
|
||||
func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) {
|
||||
func TestStreamComponent_ConsumeOverflow_NoOp(t *testing.T) {
|
||||
c := newTestStream()
|
||||
// Commit some content directly.
|
||||
c.streamContent.WriteString("line1\nline2\nline3")
|
||||
c.phase = streamPhaseActive
|
||||
c.renderDirty = true
|
||||
|
||||
// ConsumeOverflow is a no-op in alt screen mode — always returns "".
|
||||
if got := c.ConsumeOverflow(); got != "" {
|
||||
t.Fatalf("expected empty with height=0, got %q", got)
|
||||
t.Fatalf("expected empty from no-op ConsumeOverflow, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits
|
||||
// within the allocated height, ConsumeOverflow returns "".
|
||||
func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.streamContent.WriteString("line1\nline2")
|
||||
c.phase = streamPhaseActive
|
||||
c.renderDirty = true
|
||||
c.height = 20 // plenty of room
|
||||
|
||||
// Also returns "" with a height set.
|
||||
c.height = 2
|
||||
if got := c.ConsumeOverflow(); got != "" {
|
||||
t.Fatalf("expected empty when content fits, got %q", got)
|
||||
t.Fatalf("expected empty from no-op ConsumeOverflow with height, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the
|
||||
// rendered content has more lines than the allocated height, ConsumeOverflow
|
||||
// returns the top overflow lines and advances the internal pointer.
|
||||
func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) {
|
||||
// TestStreamComponent_GetRenderedContent_ReturnsAll verifies that
|
||||
// GetRenderedContent returns all accumulated content.
|
||||
func TestStreamComponent_GetRenderedContent_ReturnsAll(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.height = 2
|
||||
|
||||
// Build raw content that when "rendered" (plain text for this test)
|
||||
// is 5 lines — we bypass the markdown renderer by writing directly to
|
||||
// streamContent and using a nil renderer.
|
||||
c.renderer = nil
|
||||
c.phase = streamPhaseActive
|
||||
|
||||
c.streamContent.WriteString("a\nb\nc\nd\ne")
|
||||
c.phase = streamPhaseActive
|
||||
c.renderDirty = true
|
||||
|
||||
// First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow).
|
||||
overflow1 := c.ConsumeOverflow()
|
||||
if overflow1 == "" {
|
||||
t.Fatal("expected overflow, got empty")
|
||||
}
|
||||
overflowLines := strings.Split(overflow1, "\n")
|
||||
if len(overflowLines) != 3 {
|
||||
t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1)
|
||||
}
|
||||
if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" {
|
||||
t.Fatalf("unexpected overflow lines: %v", overflowLines)
|
||||
}
|
||||
|
||||
// Second call without new content should return "" (pointer already advanced).
|
||||
overflow2 := c.ConsumeOverflow()
|
||||
if overflow2 != "" {
|
||||
t.Fatalf("expected empty on second call, got %q", overflow2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new
|
||||
// content arrives, ConsumeOverflow incrementally returns only newly overflowed
|
||||
// lines on each call.
|
||||
func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.height = 2
|
||||
c.renderer = nil
|
||||
c.phase = streamPhaseActive
|
||||
|
||||
// Start with 3 lines — 1 overflows.
|
||||
c.streamContent.WriteString("a\nb\nc")
|
||||
c.renderDirty = true
|
||||
|
||||
overflow1 := c.ConsumeOverflow()
|
||||
if overflow1 != "a" {
|
||||
t.Fatalf("expected 'a', got %q", overflow1)
|
||||
}
|
||||
|
||||
// Add 2 more lines — 2 additional overflows.
|
||||
c.streamContent.WriteString("\nd\ne")
|
||||
c.renderDirty = true
|
||||
|
||||
overflow2 := c.ConsumeOverflow()
|
||||
want := "b\nc"
|
||||
if overflow2 != want {
|
||||
t.Fatalf("expected %q, got %q", want, overflow2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset()
|
||||
// resets the scrollback pointer so the next response starts fresh.
|
||||
func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.height = 1
|
||||
c.renderer = nil
|
||||
c.phase = streamPhaseActive
|
||||
|
||||
c.streamContent.WriteString("a\nb")
|
||||
c.renderDirty = true
|
||||
overflow := c.ConsumeOverflow()
|
||||
if overflow != "a" {
|
||||
t.Fatalf("expected 'a', got %q", overflow)
|
||||
}
|
||||
|
||||
c.Reset()
|
||||
if c.scrollbackFlushedLines != 0 {
|
||||
t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that
|
||||
// GetRenderedContent skips lines already emitted via ConsumeOverflow so the
|
||||
// caller doesn't re-print content already in the terminal scrollback.
|
||||
func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.height = 2
|
||||
c.renderer = nil
|
||||
c.phase = streamPhaseActive
|
||||
|
||||
// 5 lines → 3 overflow, 2 visible.
|
||||
c.streamContent.WriteString("a\nb\nc\nd\ne")
|
||||
c.renderDirty = true
|
||||
|
||||
// Consume the overflow: lines a, b, c.
|
||||
overflow := c.ConsumeOverflow()
|
||||
if overflow != "a\nb\nc" {
|
||||
t.Fatalf("expected 'a\\nb\\nc', got %q", overflow)
|
||||
}
|
||||
if c.scrollbackFlushedLines != 3 {
|
||||
t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines)
|
||||
}
|
||||
|
||||
// GetRenderedContent should only return the non-flushed portion: d, e.
|
||||
got := c.GetRenderedContent()
|
||||
if got != "d\ne" {
|
||||
t.Fatalf("expected 'd\\ne', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all
|
||||
// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "".
|
||||
func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.height = 1
|
||||
c.renderer = nil
|
||||
c.phase = streamPhaseActive
|
||||
|
||||
// 2 lines → height=1, so 1 overflow.
|
||||
c.streamContent.WriteString("a\nb")
|
||||
c.renderDirty = true
|
||||
|
||||
// Consume overflow (line a), leaving 1 visible line (b).
|
||||
_ = c.ConsumeOverflow()
|
||||
|
||||
// Now bump height so everything overflows — simulate a resize that made
|
||||
// the viewable area 0, forcing all content to be "flushed".
|
||||
c.scrollbackFlushedLines = 2 // pretend both lines were flushed
|
||||
|
||||
got := c.GetRenderedContent()
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty when all lines flushed, got %q", got)
|
||||
if got != "a\nb\nc\nd\ne" {
|
||||
t.Fatalf("expected full content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -8,6 +8,8 @@ import (
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// CLI manages the command-line interface for KIT, providing message rendering,
|
||||
@@ -125,7 +127,7 @@ func (c *CLI) DisplayInfo(message string) {
|
||||
// DisplayExtensionBlock renders a custom styled block with the given border
|
||||
// color and optional subtitle. Used by extensions via ctx.PrintBlock.
|
||||
func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
borderClr := theme.Info
|
||||
if borderColor != "" {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/atotto/clipboard"
|
||||
)
|
||||
|
||||
// CopyToClipboard writes text to both the system clipboard and via OSC 52.
|
||||
// Returns a tea.Cmd that can be used in Bubble Tea's Update flow.
|
||||
func CopyToClipboard(text string) tea.Cmd {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Sequence(
|
||||
// Method 1: OSC 52 escape sequence (works in modern terminals)
|
||||
tea.SetClipboard(text),
|
||||
|
||||
// Method 2: Native system clipboard (atotto/clipboard)
|
||||
func() tea.Msg {
|
||||
// Best effort - ignore errors
|
||||
_ = clipboard.WriteAll(text)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// CopyToClipboardWithMessage writes text to clipboard and returns a toast notification.
|
||||
func CopyToClipboardWithMessage(text string, message string) tea.Cmd {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Sequence(
|
||||
CopyToClipboard(text),
|
||||
func() tea.Msg {
|
||||
return ToastMsg{Message: message, Type: ToastInfo}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ToastType represents the type of toast notification.
|
||||
type ToastType int
|
||||
|
||||
const (
|
||||
ToastInfo ToastType = iota
|
||||
ToastSuccess
|
||||
ToastWarning
|
||||
ToastError
|
||||
)
|
||||
|
||||
// ToastMsg is a message to display a toast notification.
|
||||
type ToastMsg struct {
|
||||
Message string
|
||||
Type ToastType
|
||||
}
|
||||
|
||||
// IsClipboardSupported returns true if the clipboard is supported on this platform.
|
||||
func IsClipboardSupported() bool {
|
||||
// atotto/clipboard supports Linux (with xclip or xsel), macOS, Windows
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "windows":
|
||||
return true
|
||||
case "linux":
|
||||
// Check if xclip or xsel is available
|
||||
// This is a best-effort check
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CopySelection represents a text selection with start/end positions.
|
||||
type CopySelection struct {
|
||||
StartItemIdx int // Index of item where selection starts
|
||||
StartLine int // Line within item where selection starts
|
||||
StartCol int // Column where selection starts
|
||||
EndItemIdx int // Index of item where selection ends
|
||||
EndLine int // Line within item where selection ends
|
||||
EndCol int // Column where selection ends
|
||||
Active bool // Whether selection is currently active
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the selection has no content.
|
||||
func (s CopySelection) IsEmpty() bool {
|
||||
return !s.Active || (s.StartItemIdx == s.EndItemIdx && s.StartLine == s.EndLine && s.StartCol == s.EndCol)
|
||||
}
|
||||
|
||||
// String returns a string representation for debugging.
|
||||
func (s CopySelection) String() string {
|
||||
return fmt.Sprintf("Selection{item:%d-%d, line:%d-%d, col:%d-%d, active:%v}",
|
||||
s.StartItemIdx, s.EndItemIdx, s.StartLine, s.EndLine, s.StartCol, s.EndCol, s.Active)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/atotto/clipboard"
|
||||
)
|
||||
|
||||
// CopyToClipboard writes text to both the system clipboard and via OSC 52.
|
||||
// Returns a tea.Cmd that can be used in Bubble Tea's Update flow.
|
||||
func CopyToClipboard(text string) tea.Cmd {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tea.Sequence(
|
||||
// Method 1: OSC 52 escape sequence (works in modern terminals)
|
||||
tea.SetClipboard(text),
|
||||
|
||||
// Method 2: Native system clipboard (atotto/clipboard)
|
||||
func() tea.Msg {
|
||||
// Best effort - ignore errors
|
||||
_ = clipboard.WriteAll(text)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package commands
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// ListThemesFunc is set by the ui package to provide theme name completion.
|
||||
// This breaks the circular dependency between commands and ui packages.
|
||||
var ListThemesFunc func() []string
|
||||
|
||||
// SlashCommand represents a user-invokable slash command with its metadata.
|
||||
// Commands can have multiple aliases and are organized by category for better
|
||||
// discoverability and help display.
|
||||
@@ -99,7 +103,10 @@ var SlashCommands = []SlashCommand{
|
||||
Description: "Switch color theme (e.g. /theme catppuccin)",
|
||||
Category: "System",
|
||||
Complete: func(prefix string) []string {
|
||||
names := ListThemes()
|
||||
if ListThemesFunc == nil {
|
||||
return nil
|
||||
}
|
||||
names := ListThemesFunc()
|
||||
if prefix == "" {
|
||||
return names
|
||||
}
|
||||
@@ -112,6 +119,12 @@ var SlashCommands = []SlashCommand{
|
||||
return matches
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "/reload-ext",
|
||||
Description: "Hot-reload all extensions from disk",
|
||||
Category: "System",
|
||||
Aliases: []string{"/re"},
|
||||
},
|
||||
{
|
||||
Name: "/quit",
|
||||
Description: "Exit the application",
|
||||
@@ -127,7 +140,7 @@ var SlashCommands = []SlashCommand{
|
||||
},
|
||||
{
|
||||
Name: "/fork",
|
||||
Description: "Branch from an earlier message",
|
||||
Description: "Fork to new session from an earlier message",
|
||||
Category: "Navigation",
|
||||
},
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package core
|
||||
|
||||
// ImageAttachment holds a clipboard image that will be sent alongside the
|
||||
// user's text prompt to the LLM. The data is raw image bytes; MediaType is
|
||||
@@ -10,9 +10,9 @@ type ImageAttachment struct {
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// submitMsg is sent by the InputComponent when the user submits a text prompt.
|
||||
// SubmitMsg is sent by the InputComponent when the user submits a text prompt.
|
||||
// The parent model receives this and calls app.Run(Text) to start agent processing.
|
||||
type submitMsg struct {
|
||||
type SubmitMsg struct {
|
||||
// Text is the user's input text to send to the agent.
|
||||
Text string
|
||||
// Images holds clipboard image attachments to send alongside the text.
|
||||
@@ -20,10 +20,10 @@ type submitMsg struct {
|
||||
Images []ImageAttachment
|
||||
}
|
||||
|
||||
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
|
||||
// CancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
|
||||
// presses ESC once during stateWorking. If this message arrives before the user
|
||||
// presses ESC a second time, the canceling state is reset to false.
|
||||
type cancelTimerExpiredMsg struct{}
|
||||
type CancelTimerExpiredMsg struct{}
|
||||
|
||||
// --- Tree session events ---
|
||||
|
||||
@@ -42,14 +42,14 @@ type TreeNodeSelectedMsg struct {
|
||||
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
|
||||
type TreeCancelledMsg struct{}
|
||||
|
||||
// shellCommandMsg is sent by the InputComponent when the user submits a
|
||||
// ShellCommandMsg is sent by the InputComponent when the user submits a
|
||||
// ! or !! prefixed command. The parent model intercepts this to execute
|
||||
// the shell command directly instead of forwarding to the LLM.
|
||||
//
|
||||
// Matching pi's behavior:
|
||||
// - !cmd → run shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → run shell command, output EXCLUDED from LLM context
|
||||
type shellCommandMsg struct {
|
||||
type ShellCommandMsg struct {
|
||||
// Command is the shell command to execute (prefix stripped).
|
||||
Command string
|
||||
// ExcludeFromContext is true for !! (output excluded from LLM context),
|
||||
@@ -57,9 +57,9 @@ type shellCommandMsg struct {
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
// shellCommandResultMsg carries the result of a shell command execution
|
||||
// ShellCommandResultMsg carries the result of a shell command execution
|
||||
// back to the parent model for display.
|
||||
type shellCommandResultMsg struct {
|
||||
type ShellCommandResultMsg struct {
|
||||
// Command is the original shell command that was executed.
|
||||
Command string
|
||||
// Output is the combined stdout/stderr output.
|
||||
@@ -68,6 +68,6 @@ type shellCommandResultMsg struct {
|
||||
ExitCode int
|
||||
// Err is non-nil if the command failed to start or timed out.
|
||||
Err error
|
||||
// ExcludeFromContext mirrors the flag from shellCommandMsg.
|
||||
// ExcludeFromContext mirrors the flag from ShellCommandMsg.
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ui
|
||||
|
||||
// This file re-exports types from subpackages for backward compatibility.
|
||||
// External importers can continue using ui.XXX without needing to import
|
||||
// from subpackages directly.
|
||||
|
||||
import (
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
"github.com/mark3labs/kit/internal/ui/fileutil"
|
||||
"github.com/mark3labs/kit/internal/ui/prefs"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// Re-export from core package
|
||||
type (
|
||||
ImageAttachment = core.ImageAttachment
|
||||
SubmitMsg = core.SubmitMsg
|
||||
CancelTimerExpiredMsg = core.CancelTimerExpiredMsg
|
||||
TreeNodeSelectedMsg = core.TreeNodeSelectedMsg
|
||||
TreeCancelledMsg = core.TreeCancelledMsg
|
||||
ShellCommandMsg = core.ShellCommandMsg
|
||||
ShellCommandResultMsg = core.ShellCommandResultMsg
|
||||
)
|
||||
|
||||
// Re-export from commands package
|
||||
type (
|
||||
SlashCommand = commands.SlashCommand
|
||||
ExtensionCommand = commands.ExtensionCommand
|
||||
)
|
||||
|
||||
// Re-export functions from fileutil package
|
||||
var ProcessFileAttachments = fileutil.ProcessFileAttachments
|
||||
|
||||
// Re-export from prefs package
|
||||
var (
|
||||
LoadThemePreference = prefs.LoadThemePreference
|
||||
SaveThemePreference = prefs.SaveThemePreference
|
||||
LoadModelPreference = prefs.LoadModelPreference
|
||||
SaveModelPreference = prefs.SaveModelPreference
|
||||
LoadThinkingLevelPreference = prefs.LoadThinkingLevelPreference
|
||||
SaveThinkingLevelPreference = prefs.SaveThinkingLevelPreference
|
||||
)
|
||||
|
||||
// Re-export from style package
|
||||
type (
|
||||
Theme = style.Theme
|
||||
MarkdownThemeColors = style.MarkdownThemeColors
|
||||
)
|
||||
|
||||
var (
|
||||
GetTheme = style.GetTheme
|
||||
SetTheme = style.SetTheme
|
||||
DefaultTheme = style.DefaultTheme
|
||||
ApplyTheme = style.ApplyTheme
|
||||
ApplyThemeWithoutSave = style.ApplyThemeWithoutSave
|
||||
ListThemes = style.ListThemes
|
||||
RegisterThemeFromConfig = style.RegisterThemeFromConfig
|
||||
KitBanner = style.KitBanner
|
||||
AdaptiveColor = style.AdaptiveColor
|
||||
IsDarkBackground = style.IsDarkBackground
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// Renderer is the interface satisfied by MessageRenderer. It allows model.go
|
||||
@@ -30,7 +32,7 @@ var _ Renderer = (*MessageRenderer)(nil)
|
||||
// combined, styled output string with tags stripped.
|
||||
//
|
||||
// Shared by MessageRenderer.
|
||||
func parseBashOutput(result string, theme Theme) string {
|
||||
func parseBashOutput(result string, theme style.Theme) string {
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
|
||||
@@ -2,20 +2,22 @@ package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
)
|
||||
|
||||
// FuzzyMatch represents the result of a fuzzy string matching operation,
|
||||
// containing the matched command and its relevance score. Higher scores
|
||||
// indicate better matches.
|
||||
type FuzzyMatch struct {
|
||||
Command *SlashCommand
|
||||
Command *commands.SlashCommand
|
||||
Score int
|
||||
}
|
||||
|
||||
// FuzzyMatchCommands performs fuzzy string matching on the provided slash commands
|
||||
// based on the query string. Returns a slice of matches sorted by relevance score
|
||||
// in descending order. An empty query returns all commands with zero scores.
|
||||
func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch {
|
||||
func FuzzyMatchCommands(query string, commands []commands.SlashCommand) []FuzzyMatch {
|
||||
if query == "" || query == "/" {
|
||||
// Return all commands when query is empty or just "/"
|
||||
matches := make([]FuzzyMatch, len(commands))
|
||||
@@ -57,7 +59,7 @@ func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch {
|
||||
}
|
||||
|
||||
// fuzzyScore calculates the fuzzy match score for a command
|
||||
func fuzzyScore(query string, cmd *SlashCommand) int {
|
||||
func fuzzyScore(query string, cmd *commands.SlashCommand) int {
|
||||
// Check exact match first
|
||||
cmdName := strings.ToLower(strings.TrimPrefix(cmd.Name, "/"))
|
||||
if cmdName == query {
|
||||
|
||||
+92
-63
@@ -10,6 +10,9 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// InputComponent is the interactive text input field for the parent AppModel.
|
||||
@@ -29,7 +32,7 @@ import (
|
||||
// app.Run().
|
||||
type InputComponent struct {
|
||||
textarea textarea.Model
|
||||
commands []SlashCommand
|
||||
commands []commands.SlashCommand
|
||||
showPopup bool
|
||||
filtered []FuzzyMatch
|
||||
selected int
|
||||
@@ -42,17 +45,17 @@ type InputComponent struct {
|
||||
// Argument completion state. When the user types "/cmd " followed by
|
||||
// a partial argument and the command has a Complete function, the popup
|
||||
// switches to argument-completion mode showing suggestions from Complete.
|
||||
argMode bool // true when showing arg completions
|
||||
argCommand string // command prefix for arg mode (e.g. "/bookmark")
|
||||
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
|
||||
argMode bool // true when showing arg completions
|
||||
argCommand string // command prefix for arg mode (e.g. "/bookmark")
|
||||
argSynthCmds []commands.SlashCommand // backing storage for synthetic arg entries
|
||||
|
||||
// File completion state. When the user types @ followed by a partial
|
||||
// file path, the popup shows file/directory suggestions from the cwd.
|
||||
fileMode bool // true when showing @file completions
|
||||
filePrefix string // current text after @ being matched
|
||||
fileAtStartIdx int // byte offset of @ in the textarea value
|
||||
fileSuggestions []FileSuggestion // backing storage for file entries
|
||||
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
|
||||
fileMode bool // true when showing @file completions
|
||||
filePrefix string // current text after @ being matched
|
||||
fileAtStartIdx int // byte offset of @ in the textarea value
|
||||
fileSuggestions []FileSuggestion // backing storage for file entries
|
||||
fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries
|
||||
|
||||
// cwd is the working directory used for @file path resolution and
|
||||
// autocomplete suggestions. Set by the parent via SetCwd.
|
||||
@@ -71,7 +74,7 @@ type InputComponent struct {
|
||||
|
||||
// pendingImages holds clipboard images attached to the next submission.
|
||||
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
|
||||
pendingImages []ImageAttachment
|
||||
pendingImages []core.ImageAttachment
|
||||
|
||||
// history stores previously submitted prompts (most recent last).
|
||||
// Limited to maxHistory entries; duplicates of the previous entry are
|
||||
@@ -94,7 +97,7 @@ const maxHistory = 100
|
||||
|
||||
// clipboardImageMsg is the result of an async clipboard image read.
|
||||
type clipboardImageMsg struct {
|
||||
image *ImageAttachment
|
||||
image *core.ImageAttachment
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -119,7 +122,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
)
|
||||
|
||||
// Style the textarea using theme colors.
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
styles := ta.Styles()
|
||||
styles.Focused.Base = lipgloss.NewStyle()
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
@@ -130,7 +133,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
|
||||
return &InputComponent{
|
||||
textarea: ta,
|
||||
commands: SlashCommands,
|
||||
commands: commands.SlashCommands,
|
||||
width: width,
|
||||
popupHeight: 7,
|
||||
title: title,
|
||||
@@ -329,7 +332,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.filePrefix = prefix
|
||||
s.fileAtStartIdx = atIdx
|
||||
s.fileSuggestions = suggestions
|
||||
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, fs := range suggestions {
|
||||
name := fs.RelPath
|
||||
@@ -337,7 +340,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if fs.IsDir {
|
||||
desc = "directory"
|
||||
}
|
||||
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
|
||||
s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
|
||||
}
|
||||
s.selected = 0
|
||||
@@ -396,14 +399,14 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
cmd := strings.TrimSpace(trimmed[2:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
|
||||
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: true}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(trimmed, "!") {
|
||||
cmd := strings.TrimSpace(trimmed[1:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
|
||||
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,9 +414,9 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
// Resolve via canonical command lookup so aliases are handled uniformly.
|
||||
// Only /quit is handled locally — all other slash commands (including
|
||||
// /clear and /clear-queue) are forwarded to the parent model via
|
||||
// submitMsg so the parent can update its own state (scrollback, queue
|
||||
// submitMsg so the parent can update its own state (ScrollList, queue
|
||||
// counts, etc.) in one place.
|
||||
if sc := GetCommandByName(trimmed); sc != nil {
|
||||
if sc := commands.GetCommandByName(trimmed); sc != nil {
|
||||
switch sc.Name {
|
||||
case "/quit":
|
||||
return tea.Quit
|
||||
@@ -426,7 +429,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return func() tea.Msg {
|
||||
return submitMsg{Text: trimmed, Images: images}
|
||||
return core.SubmitMsg{Text: trimmed, Images: images}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +466,7 @@ func (s *InputComponent) resetHistoryBrowsing() {
|
||||
func (s *InputComponent) View() tea.View {
|
||||
containerStyle := lipgloss.NewStyle()
|
||||
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
@@ -531,14 +534,7 @@ func (s *InputComponent) View() tea.View {
|
||||
view.WriteString(helpStyle.Render(hint))
|
||||
}
|
||||
|
||||
v := tea.NewView(containerStyle.Render(view.String()))
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
return v
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
}
|
||||
|
||||
// renderPopup renders the autocomplete popup for slash command suggestions.
|
||||
@@ -565,18 +561,39 @@ func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string {
|
||||
|
||||
// renderPopupWithOptions renders the popup content with optional center styling.
|
||||
func (s *InputComponent) renderPopupWithOptions(centered bool) string {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
popupWidth := max(s.width-4, 20)
|
||||
|
||||
// Use the theme background for the popup - the full-width item backgrounds
|
||||
// and primary-colored selection will provide sufficient contrast
|
||||
popupBg := theme.Background
|
||||
|
||||
popupStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.MutedBorder).
|
||||
BorderForeground(theme.Primary).
|
||||
Background(popupBg).
|
||||
Padding(1, 2).
|
||||
Width(popupWidth).
|
||||
MarginLeft(0)
|
||||
MarginLeft(0).
|
||||
MarginBottom(1) // Visual depth/shadow effect
|
||||
|
||||
// Inner content width: popup minus border (2) and horizontal padding (4).
|
||||
innerWidth := max(popupWidth-6, 10)
|
||||
|
||||
// Item background styles for high contrast
|
||||
normalItemBg := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.Text).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
selectedItemBg := lipgloss.NewStyle().
|
||||
Background(theme.Primary).
|
||||
Foreground(theme.Background).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
var items []string
|
||||
|
||||
visibleItems := min(len(s.filtered), s.popupHeight)
|
||||
@@ -590,44 +607,45 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
|
||||
match := s.filtered[i]
|
||||
sc := match.Command
|
||||
|
||||
// Choose the appropriate background style
|
||||
itemStyle := normalItemBg
|
||||
if i == s.selected {
|
||||
itemStyle = selectedItemBg
|
||||
}
|
||||
|
||||
// Build indicator with proper coloring
|
||||
var indicator string
|
||||
if i == s.selected {
|
||||
indicator = lipgloss.NewStyle().Foreground(theme.Primary).Render("> ")
|
||||
indicator = "> "
|
||||
} else {
|
||||
indicator = " "
|
||||
}
|
||||
|
||||
nameStyle := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true)
|
||||
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
if i == s.selected {
|
||||
nameStyle = nameStyle.Foreground(theme.Primary)
|
||||
descStyle = descStyle.Foreground(theme.Text)
|
||||
}
|
||||
|
||||
// Build content with name and description
|
||||
var content string
|
||||
if s.fileMode {
|
||||
// File mode: use full width for the path, show description
|
||||
// (e.g. "directory") inline after a gap.
|
||||
// File mode: use full width for the path, show description inline
|
||||
maxNameLen := max(innerWidth-16, 8)
|
||||
displayName := sc.Name
|
||||
if len(displayName) > maxNameLen && maxNameLen > 3 {
|
||||
displayName = displayName[:maxNameLen-3] + "..."
|
||||
}
|
||||
name := nameStyle.Render(displayName)
|
||||
|
||||
if sc.Description != "" && innerWidth > 30 {
|
||||
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
|
||||
content = indicator + displayName + " " + sc.Description
|
||||
} else {
|
||||
items = append(items, indicator+name)
|
||||
content = indicator + displayName
|
||||
}
|
||||
} else {
|
||||
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc.
|
||||
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc
|
||||
if innerWidth < 20 {
|
||||
// Very narrow: show truncated name only, no fixed column.
|
||||
// Very narrow: show truncated name only
|
||||
displayName := sc.Name
|
||||
maxName := max(innerWidth-2, 3)
|
||||
if len(displayName) > maxName {
|
||||
displayName = displayName[:maxName-1] + "…"
|
||||
}
|
||||
items = append(items, indicator+nameStyle.Render(displayName))
|
||||
content = indicator + displayName
|
||||
} else {
|
||||
nameWidth := 15
|
||||
if innerWidth < 25 {
|
||||
@@ -638,33 +656,41 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
|
||||
if len(displayName) > maxNameChars {
|
||||
displayName = displayName[:maxNameChars-1] + "…"
|
||||
}
|
||||
name := nameStyle.Width(maxNameChars).Render(displayName)
|
||||
|
||||
// Description gets remaining space.
|
||||
// Description gets remaining space
|
||||
maxDescLen := max(innerWidth-nameWidth, 0)
|
||||
desc := sc.Description
|
||||
if maxDescLen < 4 {
|
||||
items = append(items, indicator+name)
|
||||
} else {
|
||||
if maxDescLen >= 4 && desc != "" {
|
||||
if len(desc) > maxDescLen {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
}
|
||||
items = append(items, indicator+name+descStyle.Render(desc))
|
||||
content = indicator + lipgloss.NewStyle().Width(maxNameChars).Render(displayName) + desc
|
||||
} else {
|
||||
content = indicator + displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, itemStyle.Render(content))
|
||||
}
|
||||
|
||||
// Add scroll indicators with background
|
||||
scrollStyle := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.VeryMuted).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
if startIdx > 0 {
|
||||
items = append([]string{lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↑ more above")}, items...)
|
||||
items = append([]string{scrollStyle.Render(" ↑ more above")}, items...)
|
||||
}
|
||||
if endIdx < len(s.filtered) {
|
||||
items = append(items, lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↓ more below"))
|
||||
items = append(items, scrollStyle.Render(" ↓ more below"))
|
||||
}
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
|
||||
// Adapt footer text to available width.
|
||||
// Adapt footer text to available width with background
|
||||
var footerText string
|
||||
if innerWidth >= 50 {
|
||||
footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
|
||||
@@ -673,7 +699,10 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
|
||||
} else {
|
||||
footerText = "↑↓ tab ↵ esc"
|
||||
}
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Italic(true).
|
||||
footer := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.VeryMuted).
|
||||
Italic(true).
|
||||
Render(footerText)
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
@@ -703,10 +732,10 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
|
||||
|
||||
s.argMode = true
|
||||
s.argCommand = cmdName
|
||||
s.argSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.argSynthCmds = make([]commands.SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
s.argSynthCmds[i] = SlashCommand{Name: sug}
|
||||
s.argSynthCmds[i] = commands.SlashCommand{Name: sug}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
|
||||
}
|
||||
return s.filtered
|
||||
@@ -714,7 +743,7 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
|
||||
|
||||
// findCommandWithComplete looks up a command by name that has a non-nil
|
||||
// Complete function.
|
||||
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
|
||||
func (s *InputComponent) findCommandWithComplete(name string) *commands.SlashCommand {
|
||||
for i := range s.commands {
|
||||
if s.commands[i].Name == name && s.commands[i].Complete != nil {
|
||||
return &s.commands[i]
|
||||
@@ -732,7 +761,7 @@ func readClipboardImageCmd() tea.Cmd {
|
||||
return clipboardImageMsg{err: err}
|
||||
}
|
||||
return clipboardImageMsg{
|
||||
image: &ImageAttachment{
|
||||
image: &core.ImageAttachment{
|
||||
Data: img.Data,
|
||||
MediaType: img.MediaType,
|
||||
},
|
||||
@@ -742,7 +771,7 @@ func readClipboardImageCmd() tea.Cmd {
|
||||
|
||||
// ClearPendingImages removes all pending image attachments and returns them.
|
||||
// Used by the parent model when consuming images for submission.
|
||||
func (s *InputComponent) ClearPendingImages() []ImageAttachment {
|
||||
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return images
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/render"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -143,47 +146,20 @@ func (s *StreamingMessageItem) Render(width int) string {
|
||||
return s.cachedRender
|
||||
}
|
||||
|
||||
// Get renderer from context
|
||||
renderer := newMessageRenderer(width, false)
|
||||
|
||||
var rendered string
|
||||
if s.role == "reasoning" {
|
||||
// Render as reasoning/thinking block with live duration counter
|
||||
theme := GetTheme()
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
ty := createTypography(theme)
|
||||
content := strings.TrimLeft(s.content, " \t\n")
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, mutedStyle.Render(ty.Italic(content)))
|
||||
|
||||
// Add live duration counter (updates on each render)
|
||||
var duration time.Duration
|
||||
// Calculate duration in milliseconds for render.ReasoningBlock
|
||||
var durationMs int64
|
||||
if s.finalDuration > 0 {
|
||||
// Streaming complete, show frozen duration
|
||||
duration = s.finalDuration
|
||||
durationMs = s.finalDuration.Milliseconds()
|
||||
} else if !s.startTime.IsZero() {
|
||||
// Still streaming, show live duration
|
||||
duration = time.Since(s.startTime)
|
||||
durationMs = time.Since(s.startTime).Milliseconds()
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
var durationStr string
|
||||
if duration < time.Second {
|
||||
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
|
||||
}
|
||||
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
|
||||
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
|
||||
parts = append(parts, label+durationStyled)
|
||||
}
|
||||
|
||||
rendered = styleMarginBottom1.Render(strings.Join(parts, "\n"))
|
||||
ty := createTypography(style.GetTheme())
|
||||
rendered = render.ReasoningBlock(s.content, durationMs, ty, style.GetTheme())
|
||||
} else {
|
||||
// Render as assistant message
|
||||
msg := renderer.RenderAssistantMessage(s.content, s.timestamp, s.modelName)
|
||||
rendered = msg.Content
|
||||
rendered = render.AssistantBlock(s.content, width, style.GetTheme())
|
||||
}
|
||||
|
||||
// Cache and return (but reasoning is never cached due to live duration)
|
||||
@@ -196,10 +172,17 @@ func (s *StreamingMessageItem) Render(width int) string {
|
||||
|
||||
// Height returns the number of lines.
|
||||
func (s *StreamingMessageItem) Height() int {
|
||||
if s.cachedRender == "" {
|
||||
// For reasoning blocks, cachedRender is never populated (rendering is
|
||||
// width-independent and includes a live timer). Fall back to Render(0)
|
||||
// so callers always get the correct height.
|
||||
rendered := s.cachedRender
|
||||
if rendered == "" {
|
||||
rendered = s.Render(0)
|
||||
}
|
||||
if rendered == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(s.cachedRender, "\n") + 1
|
||||
return strings.Count(rendered, "\n") + 1
|
||||
}
|
||||
|
||||
// AppendChunk adds a content chunk and invalidates the render cache.
|
||||
@@ -255,7 +238,7 @@ func (m *StreamingBashOutputItem) Render(width int) string {
|
||||
return m.cachedRender
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
var parts []string
|
||||
|
||||
// Header with command
|
||||
|
||||
+22
-51
@@ -9,6 +9,9 @@ import (
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/indaco/herald"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/render"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// MessageType represents different categories of messages displayed in the UI,
|
||||
@@ -138,7 +141,7 @@ func newMessageRenderer(width int, debug bool) *MessageRenderer {
|
||||
return &MessageRenderer{
|
||||
width: width,
|
||||
debug: debug,
|
||||
ty: createTypography(GetTheme()),
|
||||
ty: createTypography(style.GetTheme()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +152,7 @@ func (r *MessageRenderer) SetWidth(width int) {
|
||||
|
||||
// RenderUserMessage renders a user's input message using herald Tip alert
|
||||
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "(empty message)"
|
||||
}
|
||||
|
||||
rendered := r.ty.Tip(content)
|
||||
rendered = styleMarginBottom1.Render(rendered)
|
||||
rendered := render.UserBlock(content, r.ty, style.GetTheme())
|
||||
|
||||
return UIMessage{
|
||||
Type: UserMessage,
|
||||
@@ -166,18 +164,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
|
||||
// RenderAssistantMessage renders an AI assistant's response
|
||||
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
Content: "",
|
||||
Height: 0,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
// Use markdown rendering with Chroma syntax highlighting
|
||||
rendered := toMarkdown(content, r.width-4)
|
||||
rendered = styleMarginBottom1.Render(rendered)
|
||||
rendered := render.AssistantBlock(content, r.width, style.GetTheme())
|
||||
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
@@ -191,23 +178,7 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
// as live streaming: muted italic text with margin. This is used when resuming
|
||||
// sessions to display saved reasoning content.
|
||||
func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
Content: "",
|
||||
Height: 0,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
// Match live streaming styling: muted italic text
|
||||
// Same as stream.go renderReasoningBlock()
|
||||
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
|
||||
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
rendered := mutedStyle.Render(r.ty.Italic(contentStr))
|
||||
rendered = styleMarginBottom1.Render(rendered)
|
||||
rendered := render.ReasoningBlock(content, 0, r.ty, style.GetTheme())
|
||||
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
@@ -219,12 +190,7 @@ func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Ti
|
||||
|
||||
// RenderSystemMessage renders KIT system messages using herald Note alert
|
||||
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "No content available"
|
||||
}
|
||||
|
||||
rendered := r.ty.Note(content)
|
||||
rendered = styleMarginBottom1.Render(rendered)
|
||||
rendered := render.SystemBlock(content, r.ty, style.GetTheme())
|
||||
|
||||
return UIMessage{
|
||||
Type: SystemMessage,
|
||||
@@ -290,8 +256,7 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
|
||||
// RenderErrorMessage renders error notifications
|
||||
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
|
||||
rendered := r.ty.Caution(errorMsg)
|
||||
rendered = styleMarginBottom1.Render(rendered)
|
||||
rendered := render.ErrorBlock(errorMsg, r.ty, style.GetTheme())
|
||||
|
||||
return UIMessage{
|
||||
Type: ErrorMessage,
|
||||
@@ -323,16 +288,16 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
}
|
||||
|
||||
var icon string
|
||||
iconColor := GetTheme().Success
|
||||
iconColor := style.GetTheme().Success
|
||||
if isError {
|
||||
icon = "×"
|
||||
iconColor = GetTheme().Error
|
||||
iconColor = style.GetTheme().Error
|
||||
} else {
|
||||
icon = "✓"
|
||||
}
|
||||
|
||||
// Style the tool name with color
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
nameColor := theme.Info
|
||||
if isError {
|
||||
nameColor = theme.Error
|
||||
@@ -351,7 +316,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-8)
|
||||
if body != "" && extRd.BodyMarkdown {
|
||||
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
|
||||
body = strings.TrimSuffix(style.ToMarkdown(body, r.width-8), "\n")
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
@@ -369,6 +334,12 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
body = r.ty.Italic("(no output)")
|
||||
}
|
||||
|
||||
// Wrap all tool errors in a herald Caution alert so the error text
|
||||
// renders inside a contained block instead of spilling into the layout.
|
||||
if isError && strings.TrimSpace(body) != "" {
|
||||
body = r.ty.Alert(herald.AlertCaution, body)
|
||||
}
|
||||
|
||||
// Compose: icon + name + params, then body
|
||||
fullContent := r.ty.Compose(
|
||||
headerLine,
|
||||
@@ -397,7 +368,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string {
|
||||
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") ||
|
||||
strings.Contains(toolName, "shell") {
|
||||
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
|
||||
return parseBashOutput(result, GetTheme())
|
||||
return parseBashOutput(result, style.GetTheme())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +376,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string {
|
||||
}
|
||||
|
||||
// createTypography creates a typography instance from theme
|
||||
func createTypography(theme Theme) *herald.Typography {
|
||||
func createTypography(theme style.Theme) *herald.Typography {
|
||||
return herald.New(
|
||||
herald.WithPalette(herald.ColorPalette{
|
||||
Primary: theme.Primary,
|
||||
@@ -437,5 +408,5 @@ func createTypography(theme Theme) *herald.Typography {
|
||||
// UpdateTheme refreshes the renderer's typography instance with colors from
|
||||
// the current theme. This is called when the user changes themes via /theme.
|
||||
func (r *MessageRenderer) UpdateTheme() {
|
||||
r.ty = createTypography(GetTheme())
|
||||
r.ty = createTypography(style.GetTheme())
|
||||
}
|
||||
|
||||
+345
-311
File diff suppressed because it is too large
Load Diff
+83
-291
@@ -5,9 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
@@ -29,16 +27,14 @@ type ModelSelectedMsg struct {
|
||||
// ModelSelectorCancelledMsg is sent when the user cancels the selector.
|
||||
type ModelSelectorCancelledMsg struct{}
|
||||
|
||||
// ModelSelectorComponent is a full-screen Bubble Tea component that displays
|
||||
// a filterable list of available models. It follows the same pattern as
|
||||
// TreeSelectorComponent: inline text search, scrolling list, and custom
|
||||
// messages for result delivery.
|
||||
// ModelSelectorComponent is a Bubble Tea component that displays a filterable
|
||||
// list of available models as a centered overlay popup. It delegates rendering
|
||||
// and keyboard navigation to PopupList and converts results into the
|
||||
// ModelSelectedMsg / ModelSelectorCancelledMsg messages expected by AppModel.
|
||||
type ModelSelectorComponent struct {
|
||||
allModels []ModelEntry // all available models (pre-sorted)
|
||||
filtered []ModelEntry // subset matching the current search
|
||||
cursor int
|
||||
search string
|
||||
currentModel string // "provider/model" of the active model (for checkmark)
|
||||
popup *PopupList
|
||||
allModels []ModelEntry // kept for the custom filter callback
|
||||
currentModel string // "provider/model" of the active model
|
||||
width int
|
||||
height int
|
||||
active bool
|
||||
@@ -80,24 +76,31 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp
|
||||
return allModels[i].ModelID < allModels[j].ModelID
|
||||
})
|
||||
|
||||
ms := &ModelSelectorComponent{
|
||||
// Build PopupItems from model entries.
|
||||
items := make([]PopupItem, len(allModels))
|
||||
for i, m := range allModels {
|
||||
items[i] = PopupItem{
|
||||
Label: m.ModelID,
|
||||
Description: fmt.Sprintf("[%s]", m.Provider),
|
||||
Active: m.Provider+"/"+m.ModelID == currentModel,
|
||||
Meta: m,
|
||||
}
|
||||
}
|
||||
|
||||
popup := NewPopupList("Model Selector", items, width, height)
|
||||
popup.Subtitle = "Only showing models with configured API keys"
|
||||
popup.FilterFunc = func(query string, allItems []PopupItem) []PopupItem {
|
||||
return filterModels(query, allItems)
|
||||
}
|
||||
|
||||
return &ModelSelectorComponent{
|
||||
popup: popup,
|
||||
allModels: allModels,
|
||||
filtered: allModels,
|
||||
currentModel: currentModel,
|
||||
width: width,
|
||||
height: height,
|
||||
active: true,
|
||||
}
|
||||
|
||||
// Position cursor on the current model if found.
|
||||
for i, m := range ms.filtered {
|
||||
if m.Provider+"/"+m.ModelID == currentModel {
|
||||
ms.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ms
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
@@ -111,241 +114,94 @@ func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
ms.width = msg.Width
|
||||
ms.height = msg.Height
|
||||
ms.popup.SetSize(msg.Width, msg.Height)
|
||||
return ms, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
if ms.cursor > 0 {
|
||||
ms.cursor--
|
||||
}
|
||||
result := ms.popup.HandleKey(msg.String(), msg.Text)
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
if ms.cursor < len(ms.filtered)-1 {
|
||||
ms.cursor++
|
||||
if result.Selected != nil {
|
||||
ms.active = false
|
||||
entry := result.Selected.Meta.(ModelEntry)
|
||||
modelStr := entry.Provider + "/" + entry.ModelID
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectedMsg{ModelString: modelStr}
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
|
||||
ms.cursor -= ms.visibleHeight()
|
||||
if ms.cursor < 0 {
|
||||
ms.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
|
||||
ms.cursor += ms.visibleHeight()
|
||||
if ms.cursor >= len(ms.filtered) {
|
||||
ms.cursor = len(ms.filtered) - 1
|
||||
}
|
||||
if ms.cursor < 0 {
|
||||
ms.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
|
||||
ms.cursor = 0
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
|
||||
ms.cursor = max(len(ms.filtered)-1, 0)
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if ms.cursor < len(ms.filtered) {
|
||||
entry := ms.filtered[ms.cursor]
|
||||
ms.active = false
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectedMsg{
|
||||
ModelString: entry.Provider + "/" + entry.ModelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
if ms.search != "" {
|
||||
ms.search = ""
|
||||
ms.rebuildFiltered()
|
||||
} else {
|
||||
ms.active = false
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectorCancelledMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Inline text search.
|
||||
if msg.Text != "" && len(msg.Text) == 1 {
|
||||
ch := msg.Text[0]
|
||||
if ch >= 32 && ch < 127 {
|
||||
ms.search += string(ch)
|
||||
ms.rebuildFiltered()
|
||||
}
|
||||
}
|
||||
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ms.search) > 0 {
|
||||
ms.search = ms.search[:len(ms.search)-1]
|
||||
ms.rebuildFiltered()
|
||||
}
|
||||
if result.Cancelled {
|
||||
ms.active = false
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectorCancelledMsg{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
// View implements tea.Model — not used for overlay rendering.
|
||||
// Use RenderOverlay for the centered overlay approach.
|
||||
func (ms *ModelSelectorComponent) View() tea.View {
|
||||
theme := GetTheme()
|
||||
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(theme.Accent).
|
||||
PaddingLeft(2)
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
PaddingLeft(2)
|
||||
|
||||
infoStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Warning).
|
||||
PaddingLeft(2)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header.
|
||||
b.WriteString(headerStyle.Render("Model Selector"))
|
||||
b.WriteString("\n")
|
||||
// Adapt help text to terminal width.
|
||||
if ms.width >= 56 {
|
||||
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
|
||||
} else if ms.width >= 35 {
|
||||
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc type"))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("↑↓ ↵ esc"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if ms.width >= 48 {
|
||||
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
|
||||
} else {
|
||||
b.WriteString(infoStyle.Render("Models with API keys"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Search input.
|
||||
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(2)
|
||||
if ms.search != "" {
|
||||
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ms.search)))
|
||||
} else {
|
||||
b.WriteString(searchStyle.Render("> "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(ms.filtered) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
if ms.search != "" {
|
||||
b.WriteString(emptyStyle.Render("No models matching \"" + ms.search + "\""))
|
||||
} else {
|
||||
b.WriteString(emptyStyle.Render("No models available (check API keys)"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
// Visible window.
|
||||
visH := ms.visibleHeight()
|
||||
startIdx := 0
|
||||
if ms.cursor >= visH {
|
||||
startIdx = ms.cursor - visH + 1
|
||||
}
|
||||
endIdx := min(startIdx+visH, len(ms.filtered))
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
entry := ms.filtered[i]
|
||||
line := ms.renderEntry(entry, i == ms.cursor)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer.
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
|
||||
b.WriteString("\n")
|
||||
|
||||
footerParts := []string{
|
||||
fmt.Sprintf("(%d/%d)", ms.cursor+1, len(ms.filtered)),
|
||||
}
|
||||
if ms.cursor < len(ms.filtered) {
|
||||
entry := ms.filtered[ms.cursor]
|
||||
if entry.Name != "" {
|
||||
footerParts = append(footerParts, fmt.Sprintf("Model Name: %s", entry.Name))
|
||||
}
|
||||
if entry.ContextLimit > 0 {
|
||||
footerParts = append(footerParts, fmt.Sprintf("Context: %dK", entry.ContextLimit/1000))
|
||||
}
|
||||
}
|
||||
|
||||
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
|
||||
|
||||
v := tea.NewView(b.String())
|
||||
// Fallback full-screen rendering (unused when rendered as overlay).
|
||||
v := tea.NewView(ms.popup.RenderCentered(ms.width, ms.height))
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// RenderOverlay returns the popup as a centered overlay string, ready to be
|
||||
// composited on top of the main content via overlayContent().
|
||||
func (ms *ModelSelectorComponent) RenderOverlay(termWidth, termHeight int) string {
|
||||
return ms.popup.RenderCentered(termWidth, termHeight)
|
||||
}
|
||||
|
||||
// IsActive returns whether the selector is still accepting input.
|
||||
func (ms *ModelSelectorComponent) IsActive() bool {
|
||||
return ms.active
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
// --- Model-specific fuzzy filter ---
|
||||
|
||||
func (ms *ModelSelectorComponent) visibleHeight() int {
|
||||
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7.
|
||||
// Minimum 3 entries so the selector is still usable on short terminals.
|
||||
return max(ms.height-7, 3)
|
||||
}
|
||||
// filterModels scores and filters PopupItems whose Meta is a ModelEntry.
|
||||
func filterModels(query string, items []PopupItem) []PopupItem {
|
||||
if query == "" {
|
||||
return items
|
||||
}
|
||||
q := strings.ToLower(query)
|
||||
|
||||
func (ms *ModelSelectorComponent) rebuildFiltered() {
|
||||
if ms.search == "" {
|
||||
ms.filtered = ms.allModels
|
||||
} else {
|
||||
query := strings.ToLower(ms.search)
|
||||
ms.filtered = ms.filtered[:0]
|
||||
type scored struct {
|
||||
item PopupItem
|
||||
score int
|
||||
}
|
||||
var matches []scored
|
||||
|
||||
type scored struct {
|
||||
entry ModelEntry
|
||||
score int
|
||||
for _, item := range items {
|
||||
entry, ok := item.Meta.(ModelEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var matches []scored
|
||||
|
||||
for _, entry := range ms.allModels {
|
||||
s := ms.fuzzyScoreModel(query, entry)
|
||||
if s > 0 {
|
||||
matches = append(matches, scored{entry: entry, score: s})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, then alphabetically.
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score > matches[j].score
|
||||
}
|
||||
return matches[i].entry.ModelID < matches[j].entry.ModelID
|
||||
})
|
||||
|
||||
ms.filtered = make([]ModelEntry, len(matches))
|
||||
for i, m := range matches {
|
||||
ms.filtered[i] = m.entry
|
||||
s := fuzzyScoreModelEntry(q, entry)
|
||||
if s > 0 {
|
||||
matches = append(matches, scored{item: item, score: s})
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp cursor.
|
||||
if ms.cursor >= len(ms.filtered) {
|
||||
ms.cursor = max(len(ms.filtered)-1, 0)
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score > matches[j].score
|
||||
}
|
||||
a := matches[i].item.Meta.(ModelEntry)
|
||||
b := matches[j].item.Meta.(ModelEntry)
|
||||
return a.ModelID < b.ModelID
|
||||
})
|
||||
|
||||
result := make([]PopupItem, len(matches))
|
||||
for i, m := range matches {
|
||||
result[i] = m.item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fuzzyScoreModel scores a model entry against the search query.
|
||||
func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry) int {
|
||||
// fuzzyScoreModelEntry scores a model entry against the search query.
|
||||
func fuzzyScoreModelEntry(query string, entry ModelEntry) int {
|
||||
modelID := strings.ToLower(entry.ModelID)
|
||||
provider := strings.ToLower(entry.Provider)
|
||||
name := strings.ToLower(entry.Name)
|
||||
@@ -398,67 +254,3 @@ func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string {
|
||||
theme := GetTheme()
|
||||
modelStr := entry.ModelID
|
||||
providerStr := fmt.Sprintf("[%s]", entry.Provider)
|
||||
|
||||
// Cursor indicator.
|
||||
var cursor string
|
||||
if isCursor {
|
||||
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("-> ")
|
||||
} else {
|
||||
cursor = " "
|
||||
}
|
||||
|
||||
// Active model checkmark.
|
||||
var active string
|
||||
activeWidth := 0
|
||||
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
|
||||
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
|
||||
activeWidth = 2 // " ✓"
|
||||
}
|
||||
|
||||
// Truncate model ID and provider tag to fit terminal width.
|
||||
// Layout: cursor(3) + model + " " + provider + active.
|
||||
// Use rune length for display-width accuracy (the "…" suffix is 1 rune / 1 column).
|
||||
const cursorWidth = 3
|
||||
available := max(ms.width-cursorWidth-activeWidth-1, 10) // 1 for space between model and provider
|
||||
provDisplayLen := len([]rune(providerStr))
|
||||
modelDisplayLen := len([]rune(modelStr))
|
||||
|
||||
if modelDisplayLen+1+provDisplayLen > available {
|
||||
// Prioritize model name — truncate it, but keep provider visible.
|
||||
maxModel := max(available-provDisplayLen-1, 6)
|
||||
if maxModel < modelDisplayLen {
|
||||
if maxModel > 3 {
|
||||
runes := []rune(modelStr)
|
||||
modelStr = string(runes[:maxModel-1]) + "…"
|
||||
} else {
|
||||
runes := []rune(modelStr)
|
||||
modelStr = string(runes[:maxModel])
|
||||
}
|
||||
}
|
||||
// If provider itself is too long, drop it.
|
||||
modelDisplayLen = len([]rune(modelStr))
|
||||
if modelDisplayLen+1+provDisplayLen > available {
|
||||
providerStr = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Style the model ID.
|
||||
modelStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
if isCursor {
|
||||
modelStyle = modelStyle.Bold(true).Foreground(theme.Accent)
|
||||
}
|
||||
|
||||
// Style the provider tag.
|
||||
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
|
||||
result := cursor + modelStyle.Render(modelStr)
|
||||
if providerStr != "" {
|
||||
result += " " + providerStyle.Render(providerStr)
|
||||
}
|
||||
return result + active
|
||||
}
|
||||
|
||||
+99
-49
@@ -7,6 +7,7 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
@@ -87,7 +88,6 @@ func (s *stubAppController) Steer(prompt string) int {
|
||||
// stubStreamComponent satisfies streamComponentIface without rendering anything.
|
||||
type stubStreamComponent struct {
|
||||
resetCalled int
|
||||
height int
|
||||
lastMsg tea.Msg
|
||||
renderedContent string // returned by GetRenderedContent
|
||||
}
|
||||
@@ -99,9 +99,7 @@ func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
|
||||
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
|
||||
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
|
||||
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
|
||||
func (s *stubStreamComponent) ConsumeOverflow() string { return "" }
|
||||
func (s *stubStreamComponent) SpinnerView() string { return "" }
|
||||
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
|
||||
func (s *stubStreamComponent) HasReasoning() bool { return false }
|
||||
@@ -136,6 +134,8 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
|
||||
width: 80,
|
||||
height: 24,
|
||||
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
|
||||
scrollList: NewScrollList(80, 20),
|
||||
messages: []MessageItem{},
|
||||
}
|
||||
return m, stream, input
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestStateTransition_InputToWorking(t *testing.T) {
|
||||
t.Fatalf("expected stateInput, got %v", m.state)
|
||||
}
|
||||
|
||||
m = sendMsg(m, submitMsg{Text: "hello"})
|
||||
m = sendMsg(m, core.SubmitMsg{Text: "hello"})
|
||||
|
||||
if m.state != stateWorking {
|
||||
t.Fatalf("expected stateWorking after submitMsg, got %v", m.state)
|
||||
@@ -356,7 +356,7 @@ func TestESCCancel_timerExpiry(t *testing.T) {
|
||||
m.state = stateWorking
|
||||
m.canceling = true
|
||||
|
||||
m = sendMsg(m, cancelTimerExpiredMsg{})
|
||||
m = sendMsg(m, core.CancelTimerExpiredMsg{})
|
||||
|
||||
if m.canceling {
|
||||
t.Fatal("expected canceling=false after timer expiry")
|
||||
@@ -409,7 +409,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
_, cmd := m.Update(submitMsg{Text: "queued prompt"})
|
||||
_, cmd := m.Update(core.SubmitMsg{Text: "queued prompt"})
|
||||
|
||||
if len(m.queuedMessages) != 1 {
|
||||
t.Fatalf("expected 1 queued message, got %d", len(m.queuedMessages))
|
||||
@@ -417,7 +417,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
|
||||
if m.queuedMessages[0] != "queued prompt" {
|
||||
t.Fatalf("expected queued message text 'queued prompt', got %q", m.queuedMessages[0])
|
||||
}
|
||||
// Should NOT produce a tea.Println cmd (message is anchored, not in scrollback).
|
||||
// Should NOT flush (message is anchored in ScrollList).
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd for queued submit (message should not print to scrollback)")
|
||||
}
|
||||
@@ -507,19 +507,19 @@ func TestWindowResize_propagatesToStream(t *testing.T) {
|
||||
// sets the stream height after a resize.
|
||||
func TestWindowResize_distributeHeight(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, stream, _ := newTestAppModel(ctrl)
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
|
||||
// With height=30, stream height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
|
||||
// With height=30, scroll height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
|
||||
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
_ = m
|
||||
|
||||
if stream.height != 19 {
|
||||
t.Fatalf("expected stream height=19, got %d", stream.height)
|
||||
if m.scrollList.height != 19 {
|
||||
t.Fatalf("expected scroll list height=19, got %d", m.scrollList.height)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// tea.Println on step complete
|
||||
// Step complete behavior
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestStepComplete_preservesStreamContent verifies that StepCompleteEvent
|
||||
@@ -552,65 +552,87 @@ func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
|
||||
// cmd for the user message.
|
||||
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
|
||||
// to the ScrollList messages and triggers a layout update.
|
||||
func TestSubmitMsg_printsUserMessage(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
|
||||
_, cmd := m.Update(submitMsg{Text: "user query"})
|
||||
m = sendMsg(m, core.SubmitMsg{Text: "user query"})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
|
||||
// In alt screen mode, user messages are added to the in-memory ScrollList
|
||||
// rather than printed separately. Verify the message was added.
|
||||
found := false
|
||||
for _, msg := range m.messages {
|
||||
if tm, ok := msg.(*TextMessageItem); ok && tm.role == "user" && tm.content == "user query" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected user message 'user query' in ScrollList messages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent flushes
|
||||
// accumulated stream content but does NOT print a tool call block (the unified
|
||||
// block is printed later on ToolResultEvent).
|
||||
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent marks
|
||||
// any active StreamingMessageItem as complete and resets the stream.
|
||||
func TestToolCallStarted_flushesOnly(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, stream, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
// With no stream content, flush returns nil → cmd should be nil.
|
||||
_, cmd := m.Update(app.ToolCallStartedEvent{
|
||||
// With no stream content, nothing should change.
|
||||
initialCount := len(m.messages)
|
||||
m = sendMsg(m, app.ToolCallStartedEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: `{"cmd":"ls"}`,
|
||||
})
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd on ToolCallStartedEvent with no stream content")
|
||||
if len(m.messages) != initialCount {
|
||||
t.Fatal("expected no new messages on ToolCallStartedEvent with no stream content")
|
||||
}
|
||||
|
||||
// With stream content, flush returns tea.Println → cmd should be non-nil.
|
||||
// Simulate a StreamingMessageItem already in messages (as if appendStreamingChunk was called)
|
||||
// plus the stream component having rendered content.
|
||||
streamItem := NewStreamingMessageItem("stream-1", "assistant", "test-model")
|
||||
streamItem.AppendChunk("partial text")
|
||||
m.messages = append(m.messages, streamItem)
|
||||
stream.renderedContent = "partial text"
|
||||
_, cmd = m.Update(app.ToolCallStartedEvent{
|
||||
|
||||
_ = sendMsg(m, app.ToolCallStartedEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: `{"cmd":"ls"}`,
|
||||
})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd on ToolCallStartedEvent with stream content to flush")
|
||||
// The StreamingMessageItem should have been marked complete.
|
||||
if streamItem.streaming {
|
||||
t.Fatal("expected StreamingMessageItem to be marked complete after ToolCallStartedEvent")
|
||||
}
|
||||
// Stream should have been reset.
|
||||
if stream.resetCalled == 0 {
|
||||
t.Fatal("expected stream.Reset() to be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
|
||||
// a non-nil cmd and the stream receives a SpinnerEvent.
|
||||
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
|
||||
// the tool result to the ScrollList and the stream receives a SpinnerEvent.
|
||||
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, stream, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
_, cmd := m.Update(app.ToolResultEvent{
|
||||
initialCount := len(m.messages)
|
||||
|
||||
m = sendMsg(m, app.ToolResultEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: "{}",
|
||||
Result: "output",
|
||||
IsError: false,
|
||||
})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd on ToolResultEvent")
|
||||
// Tool result should have been added to ScrollList messages.
|
||||
if len(m.messages) <= initialCount {
|
||||
t.Fatal("expected tool result message added to ScrollList")
|
||||
}
|
||||
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
|
||||
if stream.lastMsg == nil {
|
||||
@@ -622,7 +644,7 @@ func TestToolResult_printsAndStartsSpinner(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
|
||||
// accumulates stdout and stderr lines into the streaming bash output buffers.
|
||||
// accumulates stdout and stderr lines into a StreamingBashOutputItem in the ScrollList.
|
||||
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
@@ -636,11 +658,23 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
||||
IsStderr: false,
|
||||
})
|
||||
|
||||
if len(m.streamingBashOutput) != 1 || m.streamingBashOutput[0] != "line one\n" {
|
||||
t.Fatalf("expected streamingBashOutput=['line one\\n'], got %v", m.streamingBashOutput)
|
||||
// Should have created a StreamingBashOutputItem in messages.
|
||||
var bashItem *StreamingBashOutputItem
|
||||
for _, msg := range m.messages {
|
||||
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
||||
bashItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(m.streamingBashStderr) != 0 {
|
||||
t.Fatalf("expected empty streamingBashStderr, got %v", m.streamingBashStderr)
|
||||
if bashItem == nil {
|
||||
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
|
||||
return
|
||||
}
|
||||
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
|
||||
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
|
||||
}
|
||||
if len(bashItem.stderrLines) != 0 {
|
||||
t.Fatalf("expected empty stderr, got %v", bashItem.stderrLines)
|
||||
}
|
||||
|
||||
// Send another stdout chunk.
|
||||
@@ -651,8 +685,15 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
||||
IsStderr: false,
|
||||
})
|
||||
|
||||
if len(m.streamingBashOutput) != 2 {
|
||||
t.Fatalf("expected 2 stdout lines, got %d", len(m.streamingBashOutput))
|
||||
// Re-find the bash item (same item, updated)
|
||||
bashItem = nil
|
||||
for _, msg := range m.messages {
|
||||
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
||||
bashItem = item
|
||||
}
|
||||
}
|
||||
if bashItem == nil || len(bashItem.stdoutLines) != 2 {
|
||||
t.Fatalf("expected 2 stdout lines, got %d", len(bashItem.stdoutLines))
|
||||
}
|
||||
|
||||
// Send stderr chunk.
|
||||
@@ -663,11 +704,17 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
||||
IsStderr: true,
|
||||
})
|
||||
|
||||
if len(m.streamingBashStderr) != 1 {
|
||||
t.Fatalf("expected 1 stderr line, got %d", len(m.streamingBashStderr))
|
||||
bashItem = nil
|
||||
for _, msg := range m.messages {
|
||||
if item, ok := msg.(*StreamingBashOutputItem); ok {
|
||||
bashItem = item
|
||||
}
|
||||
}
|
||||
if m.streamingBashStderr[0] != "error: something failed\n" {
|
||||
t.Fatalf("expected stderr 'error: something failed\\n', got %q", m.streamingBashStderr[0])
|
||||
if bashItem == nil || len(bashItem.stderrLines) != 1 {
|
||||
t.Fatalf("expected 1 stderr line, got %d", len(bashItem.stderrLines))
|
||||
}
|
||||
if bashItem.stderrLines[0] != "error: something failed\n" {
|
||||
t.Fatalf("expected stderr 'error: something failed\\n', got %q", bashItem.stderrLines[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,16 +796,19 @@ func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
|
||||
// produces a non-nil cmd (the tea.Println call for the error message).
|
||||
// adds an error message to the ScrollList.
|
||||
func TestStepError_printCmd(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
_, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")})
|
||||
initialCount := len(m.messages)
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error")
|
||||
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
|
||||
|
||||
// Error should have been added to ScrollList messages.
|
||||
if len(m.messages) <= initialCount {
|
||||
t.Fatal("expected error message added to ScrollList on StepErrorEvent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +878,7 @@ func TestSubmit_duringWorking_stays(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
m = sendMsg(m, submitMsg{Text: "queued prompt"})
|
||||
m = sendMsg(m, core.SubmitMsg{Text: "queued prompt"})
|
||||
|
||||
if m.state != stateWorking {
|
||||
t.Fatalf("expected stateWorking to persist after submitMsg during working, got %v", m.state)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -133,7 +135,7 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd)
|
||||
// composition. The dialog is a bordered box centered (or anchored)
|
||||
// horizontally within the terminal width.
|
||||
func (o *overlayDialog) Render() string {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
// Calculate dialog dimensions, clamped to terminal bounds.
|
||||
termW := max(o.width, 10)
|
||||
@@ -157,7 +159,7 @@ func (o *overlayDialog) Render() string {
|
||||
// Render body text (potentially as markdown).
|
||||
bodyText := o.content
|
||||
if o.markdown {
|
||||
bodyText = toMarkdown(bodyText, innerWidth)
|
||||
bodyText = style.ToMarkdown(bodyText, innerWidth)
|
||||
}
|
||||
bodyText = strings.TrimRight(bodyText, "\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// PopupItem represents a single entry in a PopupList. The component renders
|
||||
// Label as the primary text and Description as secondary text to its right.
|
||||
// The Active flag renders a checkmark to indicate the currently-active item
|
||||
// (e.g. the current model). Meta is opaque caller data returned on selection.
|
||||
type PopupItem struct {
|
||||
Label string // primary display text
|
||||
Description string // secondary text (shown right of label)
|
||||
Active bool // true → render checkmark indicator
|
||||
Meta any // opaque data returned on selection
|
||||
}
|
||||
|
||||
// PopupList is a generic, themed, scrollable fuzzy-find popup list. It is
|
||||
// rendered as a centered overlay on top of the normal TUI layout and can be
|
||||
// reused by any feature that needs a selection popup (slash commands, model
|
||||
// selector, session picker, extension-provided lists, etc.).
|
||||
//
|
||||
// The caller is responsible for:
|
||||
// - Building the initial item list
|
||||
// - Providing a fuzzy-filter callback (or nil for substring matching)
|
||||
// - Handling the result when the user selects or cancels
|
||||
//
|
||||
// Navigation: up/down to move, enter to select, esc to cancel, type to filter.
|
||||
type PopupList struct {
|
||||
// Title shown at the top of the popup.
|
||||
Title string
|
||||
// Subtitle shown below the title (dimmed).
|
||||
Subtitle string
|
||||
// FooterHint overrides the default keyboard-hint footer.
|
||||
FooterHint string
|
||||
|
||||
allItems []PopupItem // full unfiltered list
|
||||
filtered []PopupItem // subset matching the current search
|
||||
cursor int
|
||||
search string
|
||||
|
||||
// FilterFunc is called with (query, allItems) and should return the
|
||||
// filtered+scored subset. When nil, a default substring match is used.
|
||||
FilterFunc func(query string, items []PopupItem) []PopupItem
|
||||
|
||||
width int
|
||||
height int
|
||||
maxVisible int // max items visible at once (0 = auto from height)
|
||||
showSearch bool
|
||||
}
|
||||
|
||||
// PopupResult is returned by HandleKey to tell the caller what happened.
|
||||
type PopupResult struct {
|
||||
// Selected is non-nil when the user pressed Enter on an item.
|
||||
Selected *PopupItem
|
||||
// Cancelled is true when the user pressed Esc with no search text.
|
||||
Cancelled bool
|
||||
// Changed is true when the search or cursor moved (caller should re-render).
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// NewPopupList creates a new popup list with the given items and dimensions.
|
||||
func NewPopupList(title string, items []PopupItem, width, height int) *PopupList {
|
||||
p := &PopupList{
|
||||
Title: title,
|
||||
allItems: items,
|
||||
filtered: items,
|
||||
width: width,
|
||||
height: height,
|
||||
showSearch: true,
|
||||
}
|
||||
// Position cursor on the active item if one exists.
|
||||
for i, item := range p.filtered {
|
||||
if item.Active {
|
||||
p.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// SetSize updates the popup dimensions (e.g. on window resize).
|
||||
func (p *PopupList) SetSize(width, height int) {
|
||||
p.width = width
|
||||
p.height = height
|
||||
}
|
||||
|
||||
// visibleCount returns the number of items visible at once.
|
||||
func (p *PopupList) visibleCount() int {
|
||||
if p.maxVisible > 0 {
|
||||
return p.maxVisible
|
||||
}
|
||||
// Reserve: title(1) + subtitle(1) + search(1) + separator(1) + footer(2) + border(2) + padding(2) = 10
|
||||
overhead := 8
|
||||
if p.Subtitle != "" {
|
||||
overhead++
|
||||
}
|
||||
if p.showSearch {
|
||||
overhead += 2 // search line + separator
|
||||
}
|
||||
return max(p.height/2-overhead, 3)
|
||||
}
|
||||
|
||||
// HandleKey processes a single key event and returns the result. The caller
|
||||
// should inspect PopupResult to decide whether to re-render, close the popup,
|
||||
// or act on a selection.
|
||||
//
|
||||
// keyName is the Bubble Tea key string (e.g. "up", "down", "enter", "esc").
|
||||
// keyText is the printable text for character keys (e.g. "a", "1").
|
||||
func (p *PopupList) HandleKey(keyName, keyText string) PopupResult {
|
||||
switch keyName {
|
||||
case "up":
|
||||
if p.cursor > 0 {
|
||||
p.cursor--
|
||||
return PopupResult{Changed: true}
|
||||
}
|
||||
return PopupResult{}
|
||||
|
||||
case "down":
|
||||
if p.cursor < len(p.filtered)-1 {
|
||||
p.cursor++
|
||||
return PopupResult{Changed: true}
|
||||
}
|
||||
return PopupResult{}
|
||||
|
||||
case "pgup":
|
||||
p.cursor -= p.visibleCount()
|
||||
if p.cursor < 0 {
|
||||
p.cursor = 0
|
||||
}
|
||||
return PopupResult{Changed: true}
|
||||
|
||||
case "pgdown":
|
||||
p.cursor += p.visibleCount()
|
||||
if p.cursor >= len(p.filtered) {
|
||||
p.cursor = max(len(p.filtered)-1, 0)
|
||||
}
|
||||
return PopupResult{Changed: true}
|
||||
|
||||
case "home":
|
||||
p.cursor = 0
|
||||
return PopupResult{Changed: true}
|
||||
|
||||
case "end":
|
||||
p.cursor = max(len(p.filtered)-1, 0)
|
||||
return PopupResult{Changed: true}
|
||||
|
||||
case "enter":
|
||||
if p.cursor < len(p.filtered) {
|
||||
item := p.filtered[p.cursor]
|
||||
return PopupResult{Selected: &item}
|
||||
}
|
||||
return PopupResult{}
|
||||
|
||||
case "esc":
|
||||
if p.search != "" {
|
||||
p.search = ""
|
||||
p.rebuildFiltered()
|
||||
return PopupResult{Changed: true}
|
||||
}
|
||||
return PopupResult{Cancelled: true}
|
||||
|
||||
case "backspace":
|
||||
if len(p.search) > 0 {
|
||||
p.search = p.search[:len(p.search)-1]
|
||||
p.rebuildFiltered()
|
||||
return PopupResult{Changed: true}
|
||||
}
|
||||
return PopupResult{}
|
||||
|
||||
default:
|
||||
// Printable character → append to search.
|
||||
if keyText != "" && len(keyText) == 1 {
|
||||
ch := keyText[0]
|
||||
if ch >= 32 && ch < 127 {
|
||||
p.search += string(ch)
|
||||
p.rebuildFiltered()
|
||||
return PopupResult{Changed: true}
|
||||
}
|
||||
}
|
||||
return PopupResult{}
|
||||
}
|
||||
}
|
||||
|
||||
// Render returns the styled popup content (bordered box) ready to be placed
|
||||
// as a centered overlay via lipgloss.Place + overlayContent.
|
||||
func (p *PopupList) Render() string {
|
||||
theme := style.GetTheme()
|
||||
popupWidth := max(min(p.width-4, 80), 20)
|
||||
popupBg := theme.Background
|
||||
|
||||
popupStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.Primary).
|
||||
Background(popupBg).
|
||||
Padding(1, 2).
|
||||
Width(popupWidth).
|
||||
MarginBottom(1)
|
||||
|
||||
// Inner content width: popup minus border (2) and horizontal padding (4).
|
||||
innerWidth := max(popupWidth-6, 10)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Title.
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(theme.Accent).
|
||||
Background(popupBg).
|
||||
Width(innerWidth)
|
||||
b.WriteString(titleStyle.Render(p.Title))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Subtitle.
|
||||
if p.Subtitle != "" {
|
||||
subtitleStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Background(popupBg).
|
||||
Width(innerWidth)
|
||||
b.WriteString(subtitleStyle.Render(p.Subtitle))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Search input.
|
||||
if p.showSearch {
|
||||
searchStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Info).
|
||||
Background(popupBg).
|
||||
Width(innerWidth)
|
||||
if p.search != "" {
|
||||
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", p.search)))
|
||||
} else {
|
||||
b.WriteString(searchStyle.Render("> "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Separator.
|
||||
sepStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Background(popupBg)
|
||||
b.WriteString(sepStyle.Render(strings.Repeat("─", innerWidth)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Item list.
|
||||
normalItemBg := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.Text).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
selectedItemBg := lipgloss.NewStyle().
|
||||
Background(theme.Primary).
|
||||
Foreground(theme.Background).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
scrollStyle := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.VeryMuted).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
vis := p.visibleCount()
|
||||
var items []string
|
||||
|
||||
if len(p.filtered) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Background(popupBg).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
if p.search != "" {
|
||||
items = append(items, emptyStyle.Render("No matches for \""+p.search+"\""))
|
||||
} else {
|
||||
items = append(items, emptyStyle.Render("No items"))
|
||||
}
|
||||
} else {
|
||||
startIdx := 0
|
||||
if p.cursor >= vis {
|
||||
startIdx = p.cursor - vis + 1
|
||||
}
|
||||
endIdx := min(startIdx+vis, len(p.filtered))
|
||||
|
||||
if startIdx > 0 {
|
||||
items = append(items, scrollStyle.Render(" ↑ more above"))
|
||||
}
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
entry := p.filtered[i]
|
||||
isCursor := i == p.cursor
|
||||
|
||||
itemStyle := normalItemBg
|
||||
if isCursor {
|
||||
itemStyle = selectedItemBg
|
||||
}
|
||||
|
||||
// Build indicator.
|
||||
var indicator string
|
||||
if isCursor {
|
||||
indicator = "> "
|
||||
} else {
|
||||
indicator = " "
|
||||
}
|
||||
|
||||
// Build content: indicator + label + description + active checkmark.
|
||||
content := p.renderItemContent(indicator, entry, innerWidth, isCursor)
|
||||
items = append(items, itemStyle.Render(content))
|
||||
}
|
||||
|
||||
if endIdx < len(p.filtered) {
|
||||
items = append(items, scrollStyle.Render(" ↓ more below"))
|
||||
}
|
||||
}
|
||||
|
||||
content := b.String() + strings.Join(items, "\n")
|
||||
|
||||
// Footer with count and keyboard hints.
|
||||
var footerParts []string
|
||||
footerParts = append(footerParts, fmt.Sprintf("(%d/%d)", p.cursor+1, len(p.filtered)))
|
||||
|
||||
footerHint := p.FooterHint
|
||||
if footerHint == "" {
|
||||
if innerWidth >= 50 {
|
||||
footerHint = "↑↓ navigate • enter select • esc cancel • type to filter"
|
||||
} else if innerWidth >= 30 {
|
||||
footerHint = "↑↓ nav • ↵ select • esc"
|
||||
} else {
|
||||
footerHint = "↑↓ ↵ esc"
|
||||
}
|
||||
}
|
||||
footerParts = append(footerParts, footerHint)
|
||||
|
||||
footer := lipgloss.NewStyle().
|
||||
Background(popupBg).
|
||||
Foreground(theme.VeryMuted).
|
||||
Italic(true).
|
||||
Render(strings.Join(footerParts, " "))
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
}
|
||||
|
||||
// RenderCentered returns the popup placed at the center of a termWidth×termHeight
|
||||
// canvas, ready to be composed with overlayContent().
|
||||
func (p *PopupList) RenderCentered(termWidth, termHeight int) string {
|
||||
popupContent := p.Render()
|
||||
return lipgloss.Place(
|
||||
termWidth,
|
||||
termHeight,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
popupContent,
|
||||
)
|
||||
}
|
||||
|
||||
// IsSearching returns true when the search input is non-empty.
|
||||
func (p *PopupList) IsSearching() bool {
|
||||
return p.search != ""
|
||||
}
|
||||
|
||||
// SelectedItem returns the item under the cursor, or nil if the list is empty.
|
||||
func (p *PopupList) SelectedItem() *PopupItem {
|
||||
if p.cursor < len(p.filtered) {
|
||||
item := p.filtered[p.cursor]
|
||||
return &item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (p *PopupList) rebuildFiltered() {
|
||||
if p.FilterFunc != nil {
|
||||
p.filtered = p.FilterFunc(p.search, p.allItems)
|
||||
} else {
|
||||
p.filtered = defaultFilter(p.search, p.allItems)
|
||||
}
|
||||
// Clamp cursor.
|
||||
if p.cursor >= len(p.filtered) {
|
||||
p.cursor = max(len(p.filtered)-1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultFilter is a simple case-insensitive substring + fuzzy character match.
|
||||
func defaultFilter(query string, items []PopupItem) []PopupItem {
|
||||
if query == "" {
|
||||
return items
|
||||
}
|
||||
q := strings.ToLower(query)
|
||||
type scored struct {
|
||||
item PopupItem
|
||||
score int
|
||||
}
|
||||
var matches []scored
|
||||
for _, item := range items {
|
||||
label := strings.ToLower(item.Label)
|
||||
desc := strings.ToLower(item.Description)
|
||||
|
||||
var s int
|
||||
switch {
|
||||
case label == q:
|
||||
s = 1000
|
||||
case strings.HasPrefix(label, q):
|
||||
s = 800 - len(label) + len(q)
|
||||
case strings.Contains(label, q):
|
||||
s = 600
|
||||
case strings.Contains(desc, q):
|
||||
s = 400
|
||||
default:
|
||||
s = fuzzyCharacterMatch(q, label)
|
||||
}
|
||||
if s > 0 {
|
||||
matches = append(matches, scored{item: item, score: s})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, then alphabetically by label.
|
||||
for i := 0; i < len(matches)-1; i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[j].score > matches[i].score ||
|
||||
(matches[j].score == matches[i].score && matches[j].item.Label < matches[i].item.Label) {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]PopupItem, len(matches))
|
||||
for i, m := range matches {
|
||||
result[i] = m.item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renderItemContent builds the display string for a single item row.
|
||||
func (p *PopupList) renderItemContent(indicator string, entry PopupItem, innerWidth int, isCursor bool) string {
|
||||
theme := style.GetTheme()
|
||||
|
||||
// Reserve space: indicator(2) + potential checkmark(2)
|
||||
activeWidth := 0
|
||||
if entry.Active {
|
||||
activeWidth = 2
|
||||
}
|
||||
available := max(innerWidth-2-activeWidth, 6) // 2 for indicator, already included
|
||||
|
||||
label := entry.Label
|
||||
desc := entry.Description
|
||||
|
||||
if desc != "" {
|
||||
// Two-column layout: label + description.
|
||||
descWidth := len([]rune(desc)) + 1 // 1 space gap
|
||||
labelMax := max(available-descWidth, available*2/3)
|
||||
if len([]rune(label)) > labelMax && labelMax > 3 {
|
||||
runes := []rune(label)
|
||||
label = string(runes[:labelMax-1]) + "…"
|
||||
}
|
||||
labelDisplayLen := len([]rune(label))
|
||||
|
||||
// If label + desc don't fit, truncate or drop desc.
|
||||
if labelDisplayLen+1+len([]rune(desc)) > available {
|
||||
remaining := available - labelDisplayLen - 1
|
||||
if remaining >= 4 {
|
||||
runes := []rune(desc)
|
||||
if len(runes) > remaining {
|
||||
desc = string(runes[:remaining-1]) + "…"
|
||||
}
|
||||
} else {
|
||||
desc = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single column: just the label.
|
||||
if len([]rune(label)) > available && available > 3 {
|
||||
runes := []rune(label)
|
||||
label = string(runes[:available-1]) + "…"
|
||||
}
|
||||
}
|
||||
|
||||
result := indicator + label
|
||||
if desc != "" {
|
||||
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
if isCursor {
|
||||
// When selected, use a dimmer foreground that still contrasts with Primary bg.
|
||||
descStyle = lipgloss.NewStyle().Foreground(theme.Background)
|
||||
}
|
||||
result += " " + descStyle.Render(desc)
|
||||
}
|
||||
if entry.Active {
|
||||
checkStyle := lipgloss.NewStyle().Foreground(theme.Success)
|
||||
if isCursor {
|
||||
checkStyle = lipgloss.NewStyle().Foreground(theme.Background)
|
||||
}
|
||||
result += checkStyle.Render(" ✓")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPopupList_NewPositionsCursorOnActiveItem(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha"},
|
||||
{Label: "beta"},
|
||||
{Label: "gamma", Active: true},
|
||||
{Label: "delta"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
if p.cursor != 2 {
|
||||
t.Errorf("expected cursor on active item (index 2), got %d", p.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_HandleKey_Navigation(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha"},
|
||||
{Label: "beta"},
|
||||
{Label: "gamma"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
// Initial cursor at 0.
|
||||
if p.cursor != 0 {
|
||||
t.Fatalf("expected cursor 0, got %d", p.cursor)
|
||||
}
|
||||
|
||||
// Down → 1.
|
||||
res := p.HandleKey("down", "")
|
||||
if !res.Changed || p.cursor != 1 {
|
||||
t.Errorf("down: changed=%v cursor=%d", res.Changed, p.cursor)
|
||||
}
|
||||
|
||||
// Down → 2.
|
||||
p.HandleKey("down", "")
|
||||
if p.cursor != 2 {
|
||||
t.Errorf("expected cursor 2, got %d", p.cursor)
|
||||
}
|
||||
|
||||
// Down at end → stays at 2.
|
||||
res = p.HandleKey("down", "")
|
||||
if p.cursor != 2 {
|
||||
t.Errorf("down at end: expected cursor 2, got %d", p.cursor)
|
||||
}
|
||||
|
||||
// Up → 1.
|
||||
res = p.HandleKey("up", "")
|
||||
if !res.Changed || p.cursor != 1 {
|
||||
t.Errorf("up: changed=%v cursor=%d", res.Changed, p.cursor)
|
||||
}
|
||||
|
||||
// Home → 0.
|
||||
p.HandleKey("home", "")
|
||||
if p.cursor != 0 {
|
||||
t.Errorf("home: expected cursor 0, got %d", p.cursor)
|
||||
}
|
||||
|
||||
// End → 2.
|
||||
p.HandleKey("end", "")
|
||||
if p.cursor != 2 {
|
||||
t.Errorf("end: expected cursor 2, got %d", p.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_HandleKey_Search(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "apple"},
|
||||
{Label: "banana"},
|
||||
{Label: "cherry"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
// Type "an" → should filter to banana.
|
||||
p.HandleKey("a", "a")
|
||||
p.HandleKey("n", "n")
|
||||
|
||||
if !p.IsSearching() {
|
||||
t.Error("expected IsSearching() to be true")
|
||||
}
|
||||
if len(p.filtered) == 0 {
|
||||
t.Fatal("expected at least one filtered result")
|
||||
}
|
||||
// banana should match (contains "an").
|
||||
found := false
|
||||
for _, item := range p.filtered {
|
||||
if item.Label == "banana" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected 'banana' in filtered results")
|
||||
}
|
||||
|
||||
// Backspace removes last char.
|
||||
p.HandleKey("backspace", "")
|
||||
if p.search != "a" {
|
||||
t.Errorf("expected search 'a' after backspace, got %q", p.search)
|
||||
}
|
||||
|
||||
// Esc clears search.
|
||||
res := p.HandleKey("esc", "")
|
||||
if res.Cancelled {
|
||||
t.Error("esc with search should clear search, not cancel")
|
||||
}
|
||||
if p.search != "" {
|
||||
t.Errorf("expected empty search after esc, got %q", p.search)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_HandleKey_SelectAndCancel(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha", Meta: "first"},
|
||||
{Label: "beta", Meta: "second"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
// Select first item.
|
||||
res := p.HandleKey("enter", "")
|
||||
if res.Selected == nil {
|
||||
t.Fatal("expected a selection on enter")
|
||||
}
|
||||
if res.Selected.Label != "alpha" {
|
||||
t.Errorf("expected 'alpha', got %q", res.Selected.Label)
|
||||
}
|
||||
if res.Selected.Meta != "first" {
|
||||
t.Errorf("expected meta 'first', got %v", res.Selected.Meta)
|
||||
}
|
||||
|
||||
// Cancel with esc (no search text).
|
||||
p2 := NewPopupList("Test", items, 80, 40)
|
||||
res = p2.HandleKey("esc", "")
|
||||
if !res.Cancelled {
|
||||
t.Error("expected Cancelled on esc with no search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_DefaultFilter(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "foo-bar"},
|
||||
{Label: "baz-qux"},
|
||||
{Label: "foobar"},
|
||||
}
|
||||
|
||||
// Exact prefix.
|
||||
result := defaultFilter("foo", items)
|
||||
if len(result) < 2 {
|
||||
t.Fatalf("expected at least 2 matches for 'foo', got %d", len(result))
|
||||
}
|
||||
// "foobar" should rank higher (shorter match) or equal to "foo-bar".
|
||||
if result[0].Label != "foobar" && result[1].Label != "foobar" {
|
||||
t.Error("expected 'foobar' in top results")
|
||||
}
|
||||
|
||||
// No match.
|
||||
result = defaultFilter("zzz", items)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 matches for 'zzz', got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_CustomFilterFunc(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha"},
|
||||
{Label: "beta"},
|
||||
{Label: "gamma"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
p.FilterFunc = func(query string, allItems []PopupItem) []PopupItem {
|
||||
// Custom: only return items whose label starts with query.
|
||||
var result []PopupItem
|
||||
for _, item := range allItems {
|
||||
if strings.HasPrefix(item.Label, query) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
p.HandleKey("b", "b")
|
||||
if len(p.filtered) != 1 || p.filtered[0].Label != "beta" {
|
||||
t.Errorf("expected ['beta'], got %v", p.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_Render(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha", Description: "[test]"},
|
||||
{Label: "beta", Description: "[test]", Active: true},
|
||||
}
|
||||
p := NewPopupList("My List", items, 80, 40)
|
||||
p.Subtitle = "Some subtitle"
|
||||
|
||||
rendered := p.Render()
|
||||
if rendered == "" {
|
||||
t.Fatal("expected non-empty rendered output")
|
||||
}
|
||||
|
||||
// Strip ANSI escape sequences for content checking.
|
||||
plain := stripAnsi(rendered)
|
||||
if !strings.Contains(plain, "My List") {
|
||||
t.Error("expected title 'My List' in rendered output")
|
||||
}
|
||||
if !strings.Contains(plain, "alpha") {
|
||||
t.Error("expected 'alpha' in rendered output")
|
||||
}
|
||||
if !strings.Contains(plain, "beta") {
|
||||
t.Error("expected 'beta' in rendered output")
|
||||
}
|
||||
if !strings.Contains(plain, "✓") {
|
||||
t.Error("expected checkmark for active item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_RenderCentered(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "item1"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
centered := p.RenderCentered(80, 40)
|
||||
if centered == "" {
|
||||
t.Fatal("expected non-empty centered output")
|
||||
}
|
||||
// Should contain newlines for vertical centering.
|
||||
lines := strings.Split(centered, "\n")
|
||||
if len(lines) < 10 {
|
||||
t.Errorf("expected centered output to have many lines, got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_EmptyItems(t *testing.T) {
|
||||
p := NewPopupList("Empty", nil, 80, 40)
|
||||
|
||||
rendered := p.Render()
|
||||
if !strings.Contains(rendered, "No items") {
|
||||
t.Error("expected 'No items' for empty list")
|
||||
}
|
||||
|
||||
// Navigate on empty list shouldn't panic.
|
||||
p.HandleKey("down", "")
|
||||
p.HandleKey("up", "")
|
||||
res := p.HandleKey("enter", "")
|
||||
if res.Selected != nil {
|
||||
t.Error("enter on empty list should not select")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_SearchNoResults(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha"},
|
||||
{Label: "beta"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
// Type something that doesn't match.
|
||||
p.HandleKey("z", "z")
|
||||
p.HandleKey("z", "z")
|
||||
p.HandleKey("z", "z")
|
||||
|
||||
rendered := p.Render()
|
||||
if !strings.Contains(rendered, "No matches") {
|
||||
t.Error("expected 'No matches' message for empty search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupList_CursorClamping(t *testing.T) {
|
||||
items := []PopupItem{
|
||||
{Label: "alpha"},
|
||||
{Label: "beta"},
|
||||
{Label: "gamma"},
|
||||
}
|
||||
p := NewPopupList("Test", items, 80, 40)
|
||||
|
||||
// Move to last item.
|
||||
p.HandleKey("end", "")
|
||||
if p.cursor != 2 {
|
||||
t.Fatalf("expected cursor 2, got %d", p.cursor)
|
||||
}
|
||||
|
||||
// Search that reduces list to 1 item → cursor should clamp.
|
||||
p.HandleKey("a", "a")
|
||||
p.HandleKey("l", "l")
|
||||
// Only "alpha" should match.
|
||||
if p.cursor >= len(p.filtered) {
|
||||
t.Errorf("cursor %d should be < filtered count %d", p.cursor, len(p.filtered))
|
||||
}
|
||||
}
|
||||
|
||||
// stripAnsi is defined in usage_tracker_render_test.go
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,7 +206,7 @@ func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd
|
||||
// AppModel layout. The prompt replaces the normal input area (below the
|
||||
// separator and above the status bar) rather than taking over the full screen.
|
||||
func (p *promptOverlay) Render() string {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
var content string
|
||||
|
||||
switch p.mode {
|
||||
@@ -224,7 +226,7 @@ func (p *promptOverlay) Render() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewSelect(theme Theme) string {
|
||||
func (p *promptOverlay) viewSelect(theme style.Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
@@ -247,7 +249,7 @@ func (p *promptOverlay) viewSelect(theme Theme) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewConfirm(theme Theme) string {
|
||||
func (p *promptOverlay) viewConfirm(theme style.Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
@@ -272,7 +274,7 @@ func (p *promptOverlay) viewConfirm(theme Theme) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewInput(theme Theme) string {
|
||||
func (p *promptOverlay) viewInput(theme style.Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package render provides pure rendering functions for message blocks.
|
||||
// These functions are stateless and can be used by both streaming and
|
||||
// historical message rendering paths, eliminating code duplication.
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/indaco/herald"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// UserBlock renders a user message with herald Tip styling.
|
||||
func UserBlock(content string, ty *herald.Typography, theme style.Theme) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "(empty message)"
|
||||
}
|
||||
|
||||
rendered := ty.Tip(content)
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
// AssistantBlock renders an assistant message with markdown styling.
|
||||
func AssistantBlock(content string, width int, theme style.Theme) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
rendered := style.ToMarkdown(content, width-4)
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
// ReasoningBlock renders a reasoning/thinking block with muted italic text.
|
||||
// If duration > 0, shows "Thought for Xs" label. Otherwise shows just "Thought".
|
||||
func ReasoningBlock(content string, duration int64, ty *herald.Typography, theme style.Theme) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Match live streaming styling: muted italic text
|
||||
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
|
||||
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
contentRendered := mutedStyle.Render(ty.Italic(contentStr))
|
||||
|
||||
// Build label based on duration
|
||||
if duration > 0 {
|
||||
var durationStr string
|
||||
if duration < 1000 {
|
||||
durationStr = fmt.Sprintf("%dms", duration)
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", float64(duration)/1000)
|
||||
}
|
||||
labelPart := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
|
||||
durationPart := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
|
||||
label := labelPart + durationPart
|
||||
rendered := contentRendered + "\n" + label
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought")
|
||||
rendered := contentRendered + "\n" + label
|
||||
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
// SystemBlock renders a system message with herald Note styling.
|
||||
func SystemBlock(content string, ty *herald.Typography, theme style.Theme) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "No content available"
|
||||
}
|
||||
|
||||
rendered := ty.Note(content)
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
// ErrorBlock renders an error message with herald Caution styling.
|
||||
func ErrorBlock(errorMsg string, ty *herald.Typography, theme style.Theme) string {
|
||||
rendered := ty.Caution(errorMsg)
|
||||
return styleMarginBottom(theme, rendered)
|
||||
}
|
||||
|
||||
// ToolBlock renders a tool execution result with header and body.
|
||||
func ToolBlock(displayName, params, body string, isError bool, width int, ty *herald.Typography, theme style.Theme) string {
|
||||
var icon string
|
||||
iconColor := theme.Success
|
||||
if isError {
|
||||
icon = "×"
|
||||
iconColor = theme.Error
|
||||
} else {
|
||||
icon = "✓"
|
||||
}
|
||||
|
||||
// Style the tool name with color
|
||||
nameColor := theme.Info
|
||||
if isError {
|
||||
nameColor = theme.Error
|
||||
}
|
||||
styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName)
|
||||
styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon)
|
||||
|
||||
// Build the content: icon + name + params on first line, then body
|
||||
headerLine := styledIcon + " " + styledName
|
||||
if params != "" {
|
||||
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = ty.Italic("(no output)")
|
||||
}
|
||||
|
||||
// Compose: icon + name + params, then body
|
||||
fullContent := ty.Compose(
|
||||
headerLine,
|
||||
"",
|
||||
body,
|
||||
)
|
||||
return styleMarginBottom(theme, fullContent)
|
||||
}
|
||||
|
||||
// styleMarginBottom applies a 1-line margin bottom using the theme.
|
||||
func styleMarginBottom(theme style.Theme, content string) string {
|
||||
return lipgloss.NewStyle().MarginBottom(1).Render(content)
|
||||
}
|
||||
+308
-244
@@ -2,25 +2,13 @@ package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
xansi "github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/selection"
|
||||
)
|
||||
|
||||
// highlightStyle is lazily initialized to avoid creating it on every render
|
||||
var highlightStyle lipgloss.Style
|
||||
|
||||
// initHighlightStyle creates the highlight style with proper colors
|
||||
func initHighlightStyle() lipgloss.Style {
|
||||
if highlightStyle.String() == "" {
|
||||
theme := GetTheme()
|
||||
highlightStyle = lipgloss.NewStyle().
|
||||
Background(theme.Secondary).
|
||||
Foreground(theme.Background).
|
||||
Bold(true)
|
||||
}
|
||||
return highlightStyle
|
||||
}
|
||||
|
||||
// MessageItem is the interface all scrollback messages must implement.
|
||||
// This allows lazy rendering - messages are only rendered when visible.
|
||||
type MessageItem interface {
|
||||
@@ -36,8 +24,8 @@ type MessageItem interface {
|
||||
}
|
||||
|
||||
// ScrollList manages a viewport over a list of MessageItems.
|
||||
// It handles offset-based scrolling and lazy rendering. Only visible
|
||||
// items are rendered on each View() call.
|
||||
// It handles offset-based scrolling, lazy rendering, and character-level
|
||||
// text selection (crush-style). Only visible items are rendered on each View() call.
|
||||
type ScrollList struct {
|
||||
items []MessageItem
|
||||
offsetIdx int // Index of first visible item
|
||||
@@ -46,15 +34,9 @@ type ScrollList struct {
|
||||
height int // Viewport height in lines
|
||||
autoScroll bool // Whether to auto-scroll to bottom on new content
|
||||
itemGap int // Number of blank lines between items (0 = no gap)
|
||||
focusedIdx int // Index of focused/selected item (-1 = none)
|
||||
selectable bool // Whether items can be selected via mouse/keyboard
|
||||
|
||||
// Selection tracking for copy+paste (crush-style)
|
||||
selection CopySelection // Current text selection
|
||||
mouseDown bool // Whether mouse button is currently down
|
||||
mouseDownX int // X coordinate where mouse was pressed
|
||||
mouseDownY int // Y coordinate where mouse was pressed
|
||||
mouseDownItem int // Item index where mouse was pressed
|
||||
// Character-level text selection (crush-style).
|
||||
sel selection.State
|
||||
}
|
||||
|
||||
// NewScrollList creates a new ScrollList with the given dimensions.
|
||||
@@ -65,7 +47,8 @@ func NewScrollList(width, height int) *ScrollList {
|
||||
offsetLine: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
autoScroll: true, // Start with auto-scroll enabled
|
||||
autoScroll: true,
|
||||
sel: selection.NewState(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,118 +84,210 @@ func (s *ScrollList) ItemGap() int {
|
||||
return s.itemGap
|
||||
}
|
||||
|
||||
// SetSelectable enables or disables item selection.
|
||||
func (s *ScrollList) SetSelectable(selectable bool) {
|
||||
s.selectable = selectable
|
||||
}
|
||||
// --------------------------------------------------------------------------
|
||||
// Mouse event handling — character-level text selection (crush-style)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// FocusedIdx returns the currently focused item index (-1 if none).
|
||||
func (s *ScrollList) FocusedIdx() int {
|
||||
return s.focusedIdx
|
||||
}
|
||||
|
||||
// SetFocused sets the focused item by index.
|
||||
func (s *ScrollList) SetFocused(idx int) {
|
||||
if idx < -1 {
|
||||
s.focusedIdx = -1
|
||||
} else if idx >= len(s.items) {
|
||||
s.focusedIdx = len(s.items) - 1
|
||||
} else {
|
||||
s.focusedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
// SelectItemAtY selects the item at the given Y coordinate (relative to viewport).
|
||||
// Returns the selected item index or -1 if no item at that position.
|
||||
func (s *ScrollList) SelectItemAtY(y int) int {
|
||||
if !s.selectable || len(s.items) == 0 || y < 0 || y >= s.height {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Calculate which item is at the given Y position
|
||||
currentY := 0
|
||||
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
||||
item := s.items[idx]
|
||||
itemHeight := item.Height()
|
||||
|
||||
// Check if y falls within this item
|
||||
if y >= currentY && y < currentY+itemHeight {
|
||||
s.focusedIdx = idx
|
||||
return idx
|
||||
}
|
||||
|
||||
currentY += itemHeight
|
||||
|
||||
// Add gap after item (except last)
|
||||
if s.itemGap > 0 && idx < len(s.items)-1 {
|
||||
currentY += s.itemGap
|
||||
}
|
||||
|
||||
// Stop if we've passed the viewport
|
||||
if currentY >= s.height {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// HandleMouseDown handles mouse button press for selection (crush-style).
|
||||
// HandleMouseDown handles mouse button press. Detects single, double, and
|
||||
// triple clicks for character, word, and line selection respectively.
|
||||
// Returns true if the click was handled.
|
||||
func (s *ScrollList) HandleMouseDown(x, y int) bool {
|
||||
if !s.selectable || len(s.items) == 0 {
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mouseDown = true
|
||||
s.mouseDownX = x
|
||||
s.mouseDownY = y
|
||||
|
||||
// Find which item and line was clicked
|
||||
itemIdx, lineIdx := s.getItemAndLineAtY(y)
|
||||
s.mouseDownItem = itemIdx
|
||||
|
||||
// Start a new selection at click position
|
||||
if itemIdx >= 0 {
|
||||
s.selection = CopySelection{
|
||||
StartItemIdx: itemIdx,
|
||||
StartLine: lineIdx,
|
||||
StartCol: x,
|
||||
EndItemIdx: itemIdx,
|
||||
EndLine: lineIdx,
|
||||
EndCol: x,
|
||||
Active: true,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleMouseDrag handles mouse drag for selection (crush-style).
|
||||
// Updates the selection end point. Returns true if selection changed.
|
||||
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
|
||||
if !s.mouseDown || !s.selectable {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find which item and line we're dragging over
|
||||
itemIdx, lineIdx := s.getItemAndLineAtY(y)
|
||||
if itemIdx < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update selection end point
|
||||
s.selection.EndItemIdx = itemIdx
|
||||
s.selection.EndLine = lineIdx
|
||||
s.selection.EndCol = x
|
||||
s.selection.Active = true
|
||||
// Multi-click detection (crush-style).
|
||||
now := time.Now()
|
||||
if now.Sub(s.sel.LastClickTime) <= selection.DoubleClickThreshold &&
|
||||
abs(x-s.sel.LastClickX) <= selection.ClickTolerance &&
|
||||
abs(y-s.sel.LastClickY) <= selection.ClickTolerance {
|
||||
s.sel.ClickCount++
|
||||
} else {
|
||||
s.sel.ClickCount = 1
|
||||
}
|
||||
s.sel.LastClickTime = now
|
||||
s.sel.LastClickX = x
|
||||
s.sel.LastClickY = y
|
||||
|
||||
switch s.sel.ClickCount {
|
||||
case 1:
|
||||
// Single click: start character-level drag selection.
|
||||
s.sel.MouseDown = true
|
||||
s.sel.MouseDownItemIdx = itemIdx
|
||||
s.sel.MouseDownLineIdx = lineIdx
|
||||
s.sel.MouseDownCol = x
|
||||
s.sel.DragItemIdx = itemIdx
|
||||
s.sel.DragLineIdx = lineIdx
|
||||
s.sel.DragCol = x
|
||||
|
||||
case 2:
|
||||
// Double click: select word at position.
|
||||
s.selectWord(itemIdx, lineIdx, x)
|
||||
|
||||
case 3:
|
||||
// Triple click: select entire line.
|
||||
s.selectLine(itemIdx, lineIdx)
|
||||
s.sel.ClickCount = 0 // Reset after triple
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getItemAndLineAtY converts a Y coordinate to item index and line index within that item.
|
||||
// HandleMouseDrag handles mouse motion while button is held.
|
||||
// Updates the selection endpoint for character-level precision.
|
||||
// Returns true if selection was updated.
|
||||
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
|
||||
if !s.sel.MouseDown {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
itemIdx, lineIdx := s.getItemAndLineAtY(y)
|
||||
if itemIdx < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
s.sel.DragItemIdx = itemIdx
|
||||
s.sel.DragLineIdx = lineIdx
|
||||
s.sel.DragCol = x
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleMouseUp handles mouse button release.
|
||||
// Returns true if there was an active selection.
|
||||
func (s *ScrollList) HandleMouseUp() bool {
|
||||
if !s.sel.MouseDown {
|
||||
return false
|
||||
}
|
||||
s.sel.MouseDown = false
|
||||
return s.sel.HasSelection()
|
||||
}
|
||||
|
||||
// HasSelection returns true if there is a non-empty active selection.
|
||||
func (s *ScrollList) HasSelection() bool {
|
||||
return s.sel.HasSelection()
|
||||
}
|
||||
|
||||
// ClearSelection clears the current text selection.
|
||||
func (s *ScrollList) ClearSelection() {
|
||||
s.sel.Clear()
|
||||
}
|
||||
|
||||
// ExtractSelectedText returns the plain text content of the current selection
|
||||
// by walking through selected items and extracting text at the character level
|
||||
// using the ultraviolet cell buffer (ANSI-aware).
|
||||
func (s *ScrollList) ExtractSelectedText() string {
|
||||
r := s.sel.GetRange()
|
||||
if r.IsEmpty() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
for itemIdx := r.StartItemIdx; itemIdx <= r.EndItemIdx && itemIdx < len(s.items); itemIdx++ {
|
||||
item := s.items[itemIdx]
|
||||
content := item.Render(s.width)
|
||||
contentLines := strings.Split(content, "\n")
|
||||
|
||||
for lineIdx, line := range contentLines {
|
||||
inRange, startCol, endCol := selection.IsLineInRange(r, itemIdx, lineIdx)
|
||||
if !inRange {
|
||||
continue
|
||||
}
|
||||
|
||||
text := selection.ExtractText(line, startCol, endCol)
|
||||
if text != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// selectWord selects the word at the given position using UAX#29 word
|
||||
// segmentation and display-width-aware column calculations.
|
||||
func (s *ScrollList) selectWord(itemIdx, lineIdx, x int) {
|
||||
if itemIdx < 0 || itemIdx >= len(s.items) {
|
||||
return
|
||||
}
|
||||
|
||||
item := s.items[itemIdx]
|
||||
content := item.Render(s.width)
|
||||
lines := strings.Split(content, "\n")
|
||||
if lineIdx < 0 || lineIdx >= len(lines) {
|
||||
return
|
||||
}
|
||||
|
||||
// Strip ANSI codes for word boundary detection.
|
||||
plainLine := xansi.Strip(lines[lineIdx])
|
||||
startCol, endCol := selection.FindWordBoundaries(plainLine, x)
|
||||
|
||||
if startCol == endCol {
|
||||
// No word at this position — set up single-click drag state.
|
||||
s.sel.MouseDown = true
|
||||
s.sel.MouseDownItemIdx = itemIdx
|
||||
s.sel.MouseDownLineIdx = lineIdx
|
||||
s.sel.MouseDownCol = x
|
||||
s.sel.DragItemIdx = itemIdx
|
||||
s.sel.DragLineIdx = lineIdx
|
||||
s.sel.DragCol = x
|
||||
return
|
||||
}
|
||||
|
||||
// Set selection to the word boundaries.
|
||||
s.sel.MouseDown = true
|
||||
s.sel.MouseDownItemIdx = itemIdx
|
||||
s.sel.MouseDownLineIdx = lineIdx
|
||||
s.sel.MouseDownCol = startCol
|
||||
s.sel.DragItemIdx = itemIdx
|
||||
s.sel.DragLineIdx = lineIdx
|
||||
s.sel.DragCol = endCol
|
||||
}
|
||||
|
||||
// selectLine selects the entire line at the given position.
|
||||
func (s *ScrollList) selectLine(itemIdx, lineIdx int) {
|
||||
if itemIdx < 0 || itemIdx >= len(s.items) {
|
||||
return
|
||||
}
|
||||
|
||||
item := s.items[itemIdx]
|
||||
content := item.Render(s.width)
|
||||
lines := strings.Split(content, "\n")
|
||||
if lineIdx < 0 || lineIdx >= len(lines) {
|
||||
return
|
||||
}
|
||||
|
||||
lineWidth := xansi.StringWidth(lines[lineIdx])
|
||||
|
||||
s.sel.MouseDown = true
|
||||
s.sel.MouseDownItemIdx = itemIdx
|
||||
s.sel.MouseDownLineIdx = lineIdx
|
||||
s.sel.MouseDownCol = 0
|
||||
s.sel.DragItemIdx = itemIdx
|
||||
s.sel.DragLineIdx = lineIdx
|
||||
s.sel.DragCol = lineWidth
|
||||
}
|
||||
|
||||
// getItemAndLineAtY converts a viewport-relative Y coordinate to item index
|
||||
// and line index within that item. Accounts for scroll offset and item gaps.
|
||||
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
|
||||
//
|
||||
// IMPORTANT: Uses Render()+line counting (not Height()) to compute item height,
|
||||
// because Height() on some MessageItem implementations (e.g. StreamingMessageItem
|
||||
// for reasoning blocks) may return 0 when the render cache is empty.
|
||||
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
|
||||
if y < 0 || y >= s.height || len(s.items) == 0 {
|
||||
return -1, -1
|
||||
@@ -221,21 +296,27 @@ func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
|
||||
currentY := 0
|
||||
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
||||
item := s.items[idx]
|
||||
itemHeight := item.Height()
|
||||
// Compute height the same way View() does: render, then count lines.
|
||||
itemHeight := s.renderedHeight(item)
|
||||
|
||||
// Account for partial visibility of the first item.
|
||||
startLine := 0
|
||||
if idx == s.offsetIdx {
|
||||
startLine = s.offsetLine
|
||||
itemHeight -= s.offsetLine
|
||||
}
|
||||
|
||||
// Check if y falls within this item
|
||||
if y >= currentY && y < currentY+itemHeight {
|
||||
return idx, y - currentY
|
||||
return idx, (y - currentY) + startLine
|
||||
}
|
||||
|
||||
currentY += itemHeight
|
||||
|
||||
// Add gap after item (except last)
|
||||
// Add gap after item (except last).
|
||||
if s.itemGap > 0 && idx < len(s.items)-1 {
|
||||
currentY += s.itemGap
|
||||
}
|
||||
|
||||
// Stop if we've passed the viewport
|
||||
if currentY >= s.height {
|
||||
break
|
||||
}
|
||||
@@ -244,38 +325,9 @@ func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// HandleMouseUp handles mouse button release (crush-style).
|
||||
// Finalizes selection and returns true if there was an active selection.
|
||||
func (s *ScrollList) HandleMouseUp(x, y int) bool {
|
||||
if !s.mouseDown {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mouseDown = false
|
||||
|
||||
// Check if we have a valid selection
|
||||
if s.selection.Active && !s.selection.IsEmpty() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSelection returns the current text selection.
|
||||
func (s *ScrollList) GetSelection() CopySelection {
|
||||
return s.selection
|
||||
}
|
||||
|
||||
// ClearSelection clears the current text selection.
|
||||
func (s *ScrollList) ClearSelection() {
|
||||
s.selection = CopySelection{}
|
||||
s.mouseDown = false
|
||||
}
|
||||
|
||||
// HasSelection returns true if there is an active non-empty selection.
|
||||
func (s *ScrollList) HasSelection() bool {
|
||||
return s.selection.Active && !s.selection.IsEmpty()
|
||||
}
|
||||
// --------------------------------------------------------------------------
|
||||
// Scrolling
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// ScrollBy scrolls the viewport by the given number of lines.
|
||||
// Positive = scroll down, negative = scroll up.
|
||||
@@ -363,8 +415,9 @@ func (s *ScrollList) GotoBottom() {
|
||||
// Calculate total height including gaps
|
||||
totalHeight := 0
|
||||
for i, item := range s.items {
|
||||
totalHeight += item.Height()
|
||||
// Add gap after each item except the last
|
||||
rendered := item.Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
totalHeight += itemHeight
|
||||
if s.itemGap > 0 && i < len(s.items)-1 {
|
||||
totalHeight += s.itemGap
|
||||
}
|
||||
@@ -380,14 +433,14 @@ func (s *ScrollList) GotoBottom() {
|
||||
// Otherwise, position viewport at bottom
|
||||
remaining := totalHeight - s.height
|
||||
for idx := 0; idx < len(s.items); idx++ {
|
||||
itemHeight := s.items[idx].Height()
|
||||
rendered := s.items[idx].Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
if remaining < itemHeight {
|
||||
s.offsetIdx = idx
|
||||
s.offsetLine = remaining
|
||||
return
|
||||
}
|
||||
remaining -= itemHeight
|
||||
// Subtract gap after item (except last)
|
||||
if s.itemGap > 0 && idx < len(s.items)-1 {
|
||||
remaining -= s.itemGap
|
||||
}
|
||||
@@ -410,11 +463,11 @@ func (s *ScrollList) AtBottom() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate visible height from current position including gaps
|
||||
visibleHeight := 0
|
||||
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
||||
item := s.items[idx]
|
||||
itemHeight := item.Height()
|
||||
rendered := item.Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
|
||||
if idx == s.offsetIdx {
|
||||
visibleHeight += itemHeight - s.offsetLine
|
||||
@@ -422,7 +475,6 @@ func (s *ScrollList) AtBottom() bool {
|
||||
visibleHeight += itemHeight
|
||||
}
|
||||
|
||||
// Add gap after item (except last)
|
||||
if s.itemGap > 0 && idx < len(s.items)-1 {
|
||||
visibleHeight += s.itemGap
|
||||
}
|
||||
@@ -440,19 +492,28 @@ func (s *ScrollList) AtTop() bool {
|
||||
return s.offsetIdx == 0 && s.offsetLine == 0
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// View renders the visible portion of the scrollback.
|
||||
// Only items that fit within the viewport height are rendered.
|
||||
// ALWAYS returns exactly s.height lines (padded with empty lines if needed)
|
||||
// to ensure the input/footer stay fixed at the bottom.
|
||||
//
|
||||
// When an active selection exists, character-level highlighting is applied
|
||||
// using ultraviolet ScreenBuffer for ANSI-aware cell manipulation.
|
||||
func (s *ScrollList) View() string {
|
||||
if s.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
selRange := s.sel.GetRange()
|
||||
hasSelection := !selRange.IsEmpty()
|
||||
|
||||
var lines []string
|
||||
remainingHeight := s.height
|
||||
|
||||
// Render visible items
|
||||
if len(s.items) > 0 {
|
||||
for idx := s.offsetIdx; idx < len(s.items) && remainingHeight > 0; idx++ {
|
||||
item := s.items[idx]
|
||||
@@ -464,25 +525,22 @@ func (s *ScrollList) View() string {
|
||||
startLine = s.offsetLine
|
||||
}
|
||||
|
||||
// Check if this item is focused (for visual indicator)
|
||||
isFocused := idx == s.focusedIdx
|
||||
|
||||
for i := startLine; i < len(contentLines) && remainingHeight > 0; i++ {
|
||||
line := contentLines[i]
|
||||
|
||||
// Apply selection highlighting if this line is within selection
|
||||
if s.selection.Active && s.isLineInSelection(idx, i) {
|
||||
line = s.applyHighlight(line)
|
||||
} else if isFocused && s.selectable {
|
||||
// Apply subtle focus indicator when item is focused but not in selection
|
||||
line = s.applyFocusIndicator(line)
|
||||
// Apply character-level selection highlighting.
|
||||
if hasSelection {
|
||||
inRange, startCol, endCol := selection.IsLineInRange(selRange, idx, i)
|
||||
if inRange {
|
||||
line = selection.HighlightLine(line, startCol, endCol)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, line)
|
||||
remainingHeight--
|
||||
}
|
||||
|
||||
// Add gap lines between items (but not after the last visible item)
|
||||
// Add gap lines between items.
|
||||
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
|
||||
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
|
||||
lines = append(lines, "")
|
||||
@@ -492,8 +550,7 @@ func (s *ScrollList) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Pad with empty lines to ensure exactly s.height lines
|
||||
// This keeps the input/footer fixed at the bottom of the screen
|
||||
// Pad with empty lines to ensure exactly s.height lines.
|
||||
for remainingHeight > 0 {
|
||||
lines = append(lines, "")
|
||||
remainingHeight--
|
||||
@@ -502,65 +559,6 @@ func (s *ScrollList) View() string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// isLineInSelection checks if a specific line within an item is part of the current selection.
|
||||
func (s *ScrollList) isLineInSelection(itemIdx, lineIdx int) bool {
|
||||
if !s.selection.Active {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize selection (start <= end)
|
||||
startItem := s.selection.StartItemIdx
|
||||
startLine := s.selection.StartLine
|
||||
endItem := s.selection.EndItemIdx
|
||||
endLine := s.selection.EndLine
|
||||
|
||||
if startItem > endItem || (startItem == endItem && startLine > endLine) {
|
||||
startItem, endItem = endItem, startItem
|
||||
startLine, endLine = endLine, startLine
|
||||
}
|
||||
|
||||
// Check if item is within selection range
|
||||
if itemIdx < startItem || itemIdx > endItem {
|
||||
return false
|
||||
}
|
||||
|
||||
// For single item selection
|
||||
if startItem == endItem {
|
||||
return itemIdx == startItem && lineIdx >= startLine && lineIdx <= endLine
|
||||
}
|
||||
|
||||
// For multi-item selection
|
||||
if itemIdx == startItem {
|
||||
return lineIdx >= startLine
|
||||
}
|
||||
if itemIdx == endItem {
|
||||
return lineIdx <= endLine
|
||||
}
|
||||
// Middle items are fully selected
|
||||
return itemIdx > startItem && itemIdx < endItem
|
||||
}
|
||||
|
||||
// applyHighlight applies the highlight style to a line.
|
||||
// Uses the theme's Highlight color for the background.
|
||||
func (s *ScrollList) applyHighlight(line string) string {
|
||||
if line == "" {
|
||||
return line
|
||||
}
|
||||
// Apply background/foreground color change for selection
|
||||
style := initHighlightStyle()
|
||||
return style.Render(line)
|
||||
}
|
||||
|
||||
// applyFocusIndicator applies a subtle visual indicator for focused items.
|
||||
func (s *ScrollList) applyFocusIndicator(line string) string {
|
||||
if line == "" {
|
||||
return line
|
||||
}
|
||||
// Just return the line as-is - no visual indicator for focus
|
||||
// The selection highlighting is enough
|
||||
return line
|
||||
}
|
||||
|
||||
// ScrollPercent returns the current scroll position as a percentage (0.0-1.0).
|
||||
// 0.0 = at top, 1.0 = at bottom. Useful for scroll indicators.
|
||||
func (s *ScrollList) ScrollPercent() float64 {
|
||||
@@ -574,10 +572,9 @@ func (s *ScrollList) ScrollPercent() float64 {
|
||||
}
|
||||
|
||||
if totalHeight <= s.height {
|
||||
return 1.0 // All content fits, consider it "at bottom"
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Calculate how many lines are above the viewport
|
||||
linesAbove := 0
|
||||
for i := 0; i < s.offsetIdx && i < len(s.items); i++ {
|
||||
linesAbove += s.items[i].Height()
|
||||
@@ -608,7 +605,6 @@ func (s *ScrollList) clampOffset() {
|
||||
return
|
||||
}
|
||||
|
||||
// Clamp offsetIdx
|
||||
if s.offsetIdx >= len(s.items) {
|
||||
s.offsetIdx = len(s.items) - 1
|
||||
}
|
||||
@@ -616,9 +612,9 @@ func (s *ScrollList) clampOffset() {
|
||||
s.offsetIdx = 0
|
||||
}
|
||||
|
||||
// Clamp offsetLine
|
||||
if s.offsetIdx < len(s.items) {
|
||||
itemHeight := s.items[s.offsetIdx].Height()
|
||||
rendered := s.items[s.offsetIdx].Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
if s.offsetLine >= itemHeight {
|
||||
s.offsetLine = max(0, itemHeight-1)
|
||||
}
|
||||
@@ -626,4 +622,72 @@ func (s *ScrollList) clampOffset() {
|
||||
if s.offsetLine < 0 {
|
||||
s.offsetLine = 0
|
||||
}
|
||||
|
||||
// Prevent scrolling past the bottom
|
||||
totalHeight := 0
|
||||
for i, item := range s.items {
|
||||
rendered := item.Render(s.width)
|
||||
totalHeight += strings.Count(rendered, "\n") + 1
|
||||
if s.itemGap > 0 && i < len(s.items)-1 {
|
||||
totalHeight += s.itemGap
|
||||
}
|
||||
}
|
||||
|
||||
if totalHeight <= s.height {
|
||||
s.offsetIdx = 0
|
||||
s.offsetLine = 0
|
||||
return
|
||||
}
|
||||
|
||||
linesAbove := 0
|
||||
for i := 0; i < s.offsetIdx; i++ {
|
||||
rendered := s.items[i].Render(s.width)
|
||||
linesAbove += strings.Count(rendered, "\n") + 1
|
||||
if s.itemGap > 0 && i < len(s.items)-1 {
|
||||
linesAbove += s.itemGap
|
||||
}
|
||||
}
|
||||
linesAbove += s.offsetLine
|
||||
|
||||
linesFromCurrentToEnd := totalHeight - linesAbove
|
||||
if linesFromCurrentToEnd < s.height {
|
||||
targetLine := totalHeight - s.height
|
||||
currentLine := 0
|
||||
|
||||
for idx := 0; idx < len(s.items); idx++ {
|
||||
rendered := s.items[idx].Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
|
||||
if currentLine+itemHeight > targetLine {
|
||||
s.offsetIdx = idx
|
||||
s.offsetLine = targetLine - currentLine
|
||||
return
|
||||
}
|
||||
|
||||
currentLine += itemHeight
|
||||
if s.itemGap > 0 && idx < len(s.items)-1 {
|
||||
currentLine += s.itemGap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderedHeight returns the height of a message item in lines by actually
|
||||
// rendering it. This is the single source of truth for item height — it
|
||||
// matches exactly what View() produces, unlike item.Height() which may
|
||||
// return stale/zero values for uncached items (e.g. reasoning blocks).
|
||||
func (s *ScrollList) renderedHeight(item MessageItem) int {
|
||||
rendered := item.Render(s.width)
|
||||
if rendered == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rendered, "\n") + 1
|
||||
}
|
||||
|
||||
// abs returns the absolute value of x.
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// Package selection provides character-level text selection for terminal UIs.
|
||||
//
|
||||
// It handles converting mouse coordinates (in terminal cells) to character
|
||||
// positions within rendered ANSI-styled text, supporting multi-byte characters,
|
||||
// wide characters (CJK, emoji), and word/line selection via double/triple click.
|
||||
//
|
||||
// The approach is modeled after Charm's crush: all coordinate calculations use
|
||||
// display columns (terminal cells), not byte offsets or rune counts. The
|
||||
// ultraviolet ScreenBuffer provides the bridge between rendered ANSI strings
|
||||
// and individual character cells.
|
||||
package selection
|
||||
|
||||
import (
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
xansi "github.com/charmbracelet/x/ansi"
|
||||
"github.com/clipperhouse/displaywidth"
|
||||
"github.com/clipperhouse/uax29/v2/words"
|
||||
)
|
||||
|
||||
// DoubleClickThreshold is the maximum time between clicks for multi-click.
|
||||
const DoubleClickThreshold = 400 * time.Millisecond
|
||||
|
||||
// ClickTolerance is the pixel/cell tolerance for multi-click detection.
|
||||
const ClickTolerance = 2
|
||||
|
||||
// State tracks the full state of a mouse text selection.
|
||||
type State struct {
|
||||
// Whether a mouse button is currently held down.
|
||||
MouseDown bool
|
||||
|
||||
// Position where mouse was first pressed (viewport-relative).
|
||||
MouseDownItemIdx int
|
||||
MouseDownLineIdx int
|
||||
MouseDownCol int
|
||||
|
||||
// Current drag position (viewport-relative).
|
||||
DragItemIdx int
|
||||
DragLineIdx int
|
||||
DragCol int
|
||||
|
||||
// Multi-click detection.
|
||||
LastClickTime time.Time
|
||||
LastClickX int
|
||||
LastClickY int
|
||||
ClickCount int
|
||||
}
|
||||
|
||||
// Range represents a normalized (start <= end) selection range.
|
||||
type Range struct {
|
||||
StartItemIdx int
|
||||
StartLine int
|
||||
StartCol int
|
||||
EndItemIdx int
|
||||
EndLine int
|
||||
EndCol int
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the range selects nothing.
|
||||
func (r Range) IsEmpty() bool {
|
||||
return r.StartItemIdx < 0 || r.EndItemIdx < 0 ||
|
||||
(r.StartItemIdx == r.EndItemIdx && r.StartLine == r.EndLine && r.StartCol == r.EndCol)
|
||||
}
|
||||
|
||||
// NewState creates a new empty selection state.
|
||||
func NewState() State {
|
||||
return State{
|
||||
MouseDownItemIdx: -1,
|
||||
DragItemIdx: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets all selection state.
|
||||
func (s *State) Clear() {
|
||||
s.MouseDown = false
|
||||
s.MouseDownItemIdx = -1
|
||||
s.MouseDownLineIdx = 0
|
||||
s.MouseDownCol = 0
|
||||
s.DragItemIdx = -1
|
||||
s.DragLineIdx = 0
|
||||
s.DragCol = 0
|
||||
s.LastClickTime = time.Time{}
|
||||
s.LastClickX = 0
|
||||
s.LastClickY = 0
|
||||
s.ClickCount = 0
|
||||
}
|
||||
|
||||
// HasSelection returns true if there is a non-empty active selection.
|
||||
func (s *State) HasSelection() bool {
|
||||
return s.MouseDownItemIdx >= 0 && s.DragItemIdx >= 0 && !s.GetRange().IsEmpty()
|
||||
}
|
||||
|
||||
// GetRange returns the normalized selection range (start <= end).
|
||||
func (s *State) GetRange() Range {
|
||||
if s.MouseDownItemIdx < 0 || s.DragItemIdx < 0 {
|
||||
return Range{StartItemIdx: -1, EndItemIdx: -1}
|
||||
}
|
||||
|
||||
downItem := s.MouseDownItemIdx
|
||||
downLine := s.MouseDownLineIdx
|
||||
downCol := s.MouseDownCol
|
||||
dragItem := s.DragItemIdx
|
||||
dragLine := s.DragLineIdx
|
||||
dragCol := s.DragCol
|
||||
|
||||
// Determine if dragging forward or backward.
|
||||
forward := dragItem > downItem ||
|
||||
(dragItem == downItem && dragLine > downLine) ||
|
||||
(dragItem == downItem && dragLine == downLine && dragCol >= downCol)
|
||||
|
||||
if forward {
|
||||
return Range{
|
||||
StartItemIdx: downItem,
|
||||
StartLine: downLine,
|
||||
StartCol: downCol,
|
||||
EndItemIdx: dragItem,
|
||||
EndLine: dragLine,
|
||||
EndCol: dragCol,
|
||||
}
|
||||
}
|
||||
return Range{
|
||||
StartItemIdx: dragItem,
|
||||
StartLine: dragLine,
|
||||
StartCol: dragCol,
|
||||
EndItemIdx: downItem,
|
||||
EndLine: downLine,
|
||||
EndCol: downCol,
|
||||
}
|
||||
}
|
||||
|
||||
// IsLineInRange checks if a specific line within an item falls inside the
|
||||
// selection range. Returns (inRange, startCol, endCol) where startCol == -1
|
||||
// means the entire line is selected. startCol == endCol means no selection
|
||||
// on this line.
|
||||
func IsLineInRange(r Range, itemIdx, lineIdx int) (bool, int, int) {
|
||||
if r.IsEmpty() {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// Outside item range entirely.
|
||||
if itemIdx < r.StartItemIdx || itemIdx > r.EndItemIdx {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// Single-item selection.
|
||||
if r.StartItemIdx == r.EndItemIdx {
|
||||
if itemIdx != r.StartItemIdx {
|
||||
return false, 0, 0
|
||||
}
|
||||
if lineIdx < r.StartLine || lineIdx > r.EndLine {
|
||||
return false, 0, 0
|
||||
}
|
||||
if r.StartLine == r.EndLine {
|
||||
// Single line: specific column range.
|
||||
return true, r.StartCol, r.EndCol
|
||||
}
|
||||
if lineIdx == r.StartLine {
|
||||
return true, r.StartCol, -1 // from startCol to end of line
|
||||
}
|
||||
if lineIdx == r.EndLine {
|
||||
return true, 0, r.EndCol // from start of line to endCol
|
||||
}
|
||||
return true, -1, -1 // full line (middle of multi-line selection)
|
||||
}
|
||||
|
||||
// Multi-item selection.
|
||||
if itemIdx == r.StartItemIdx {
|
||||
if lineIdx < r.StartLine {
|
||||
return false, 0, 0
|
||||
}
|
||||
if lineIdx == r.StartLine {
|
||||
return true, r.StartCol, -1
|
||||
}
|
||||
return true, -1, -1 // full line
|
||||
}
|
||||
if itemIdx == r.EndItemIdx {
|
||||
if lineIdx > r.EndLine {
|
||||
return false, 0, 0
|
||||
}
|
||||
if lineIdx == r.EndLine {
|
||||
return true, 0, r.EndCol
|
||||
}
|
||||
return true, -1, -1 // full line
|
||||
}
|
||||
|
||||
// Middle item: fully selected.
|
||||
return true, -1, -1
|
||||
}
|
||||
|
||||
// FindWordBoundaries finds the start and end column of the word at the given
|
||||
// column position in a plain-text line (ANSI codes already stripped).
|
||||
// Returns (startCol, endCol) where endCol is exclusive.
|
||||
// Uses UAX#29 word segmentation and display-width-aware column tracking.
|
||||
func FindWordBoundaries(line string, col int) (startCol, endCol int) {
|
||||
if line == "" || col < 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Segment the line into words using UAX#29.
|
||||
lineCol := 0
|
||||
iter := words.FromString(line)
|
||||
for iter.Next() {
|
||||
token := iter.Value()
|
||||
tokenWidth := displaywidth.String(token)
|
||||
|
||||
graphemeStart := lineCol
|
||||
graphemeEnd := lineCol + tokenWidth
|
||||
lineCol += tokenWidth
|
||||
|
||||
// If clicked before this token, no word here.
|
||||
if col < graphemeStart {
|
||||
return col, col
|
||||
}
|
||||
|
||||
// If clicked within this token, return its boundaries.
|
||||
if col >= graphemeStart && col < graphemeEnd {
|
||||
// Whitespace tokens produce empty selection.
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return col, col
|
||||
}
|
||||
return graphemeStart, graphemeEnd
|
||||
}
|
||||
}
|
||||
|
||||
return col, col
|
||||
}
|
||||
|
||||
// HighlightLine applies reverse-video highlighting to a portion of a rendered
|
||||
// line (which may contain ANSI escape codes). startCol/endCol are in display
|
||||
// columns. If startCol == -1, the entire line is highlighted. If startCol ==
|
||||
// endCol, returns the line unchanged.
|
||||
//
|
||||
// Uses ultraviolet ScreenBuffer for cell-level ANSI manipulation.
|
||||
func HighlightLine(line string, startCol, endCol int) string {
|
||||
if line == "" {
|
||||
return line
|
||||
}
|
||||
|
||||
lineWidth := xansi.StringWidth(line)
|
||||
if lineWidth == 0 {
|
||||
return line
|
||||
}
|
||||
|
||||
// Full-line highlight.
|
||||
if startCol == -1 {
|
||||
startCol = 0
|
||||
endCol = lineWidth
|
||||
}
|
||||
|
||||
if startCol >= endCol || startCol >= lineWidth {
|
||||
return line
|
||||
}
|
||||
if endCol > lineWidth {
|
||||
endCol = lineWidth
|
||||
}
|
||||
|
||||
// Parse the styled line into a cell buffer.
|
||||
area := image.Rect(0, 0, lineWidth, 1)
|
||||
buf := uv.NewScreenBuffer(lineWidth, 1)
|
||||
styled := uv.NewStyledString(line)
|
||||
styled.Draw(&buf, area)
|
||||
|
||||
// Apply reverse attribute to cells in the selection range.
|
||||
if buf.Height() > 0 {
|
||||
bufLine := buf.Line(0)
|
||||
for x := startCol; x < endCol && x < len(bufLine); x++ {
|
||||
cell := bufLine.At(x)
|
||||
if cell != nil {
|
||||
cell.Style.Attrs |= uv.AttrReverse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Render()
|
||||
}
|
||||
|
||||
// ExtractText extracts plain text from a rendered ANSI string within the given
|
||||
// column range on a single line. Uses ultraviolet to parse ANSI and extract
|
||||
// character content.
|
||||
func ExtractText(line string, startCol, endCol int) string {
|
||||
if line == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lineWidth := xansi.StringWidth(line)
|
||||
if lineWidth == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Full-line extraction.
|
||||
if startCol == -1 {
|
||||
startCol = 0
|
||||
endCol = lineWidth
|
||||
}
|
||||
|
||||
if startCol >= endCol || startCol >= lineWidth {
|
||||
return ""
|
||||
}
|
||||
if endCol > lineWidth {
|
||||
endCol = lineWidth
|
||||
}
|
||||
|
||||
// Parse to cell buffer.
|
||||
area := image.Rect(0, 0, lineWidth, 1)
|
||||
buf := uv.NewScreenBuffer(lineWidth, 1)
|
||||
styled := uv.NewStyledString(line)
|
||||
styled.Draw(&buf, area)
|
||||
|
||||
var sb strings.Builder
|
||||
if buf.Height() > 0 {
|
||||
bufLine := buf.Line(0)
|
||||
for x := startCol; x < endCol && x < len(bufLine); x++ {
|
||||
cell := bufLine.At(x)
|
||||
if cell != nil && cell.Content != "" {
|
||||
sb.WriteString(cell.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package selection
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewState(t *testing.T) {
|
||||
s := NewState()
|
||||
if s.MouseDownItemIdx != -1 {
|
||||
t.Errorf("expected MouseDownItemIdx -1, got %d", s.MouseDownItemIdx)
|
||||
}
|
||||
if s.DragItemIdx != -1 {
|
||||
t.Errorf("expected DragItemIdx -1, got %d", s.DragItemIdx)
|
||||
}
|
||||
if s.MouseDown {
|
||||
t.Error("expected MouseDown false")
|
||||
}
|
||||
if s.HasSelection() {
|
||||
t.Error("expected no selection on new state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
s := NewState()
|
||||
s.MouseDown = true
|
||||
s.MouseDownItemIdx = 2
|
||||
s.DragItemIdx = 3
|
||||
s.ClickCount = 2
|
||||
s.Clear()
|
||||
|
||||
if s.MouseDown {
|
||||
t.Error("expected MouseDown false after clear")
|
||||
}
|
||||
if s.MouseDownItemIdx != -1 {
|
||||
t.Errorf("expected MouseDownItemIdx -1 after clear, got %d", s.MouseDownItemIdx)
|
||||
}
|
||||
if s.DragItemIdx != -1 {
|
||||
t.Errorf("expected DragItemIdx -1 after clear, got %d", s.DragItemIdx)
|
||||
}
|
||||
if s.ClickCount != 0 {
|
||||
t.Errorf("expected ClickCount 0 after clear, got %d", s.ClickCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRange_Forward(t *testing.T) {
|
||||
s := NewState()
|
||||
s.MouseDownItemIdx = 0
|
||||
s.MouseDownLineIdx = 1
|
||||
s.MouseDownCol = 5
|
||||
s.DragItemIdx = 0
|
||||
s.DragLineIdx = 3
|
||||
s.DragCol = 10
|
||||
|
||||
r := s.GetRange()
|
||||
if r.StartItemIdx != 0 || r.StartLine != 1 || r.StartCol != 5 {
|
||||
t.Errorf("unexpected start: item=%d line=%d col=%d", r.StartItemIdx, r.StartLine, r.StartCol)
|
||||
}
|
||||
if r.EndItemIdx != 0 || r.EndLine != 3 || r.EndCol != 10 {
|
||||
t.Errorf("unexpected end: item=%d line=%d col=%d", r.EndItemIdx, r.EndLine, r.EndCol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRange_Backward(t *testing.T) {
|
||||
s := NewState()
|
||||
s.MouseDownItemIdx = 2
|
||||
s.MouseDownLineIdx = 5
|
||||
s.MouseDownCol = 20
|
||||
s.DragItemIdx = 0
|
||||
s.DragLineIdx = 1
|
||||
s.DragCol = 3
|
||||
|
||||
r := s.GetRange()
|
||||
// Should be normalized: drag position becomes start
|
||||
if r.StartItemIdx != 0 || r.StartLine != 1 || r.StartCol != 3 {
|
||||
t.Errorf("unexpected start: item=%d line=%d col=%d", r.StartItemIdx, r.StartLine, r.StartCol)
|
||||
}
|
||||
if r.EndItemIdx != 2 || r.EndLine != 5 || r.EndCol != 20 {
|
||||
t.Errorf("unexpected end: item=%d line=%d col=%d", r.EndItemIdx, r.EndLine, r.EndCol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRange_SameLine(t *testing.T) {
|
||||
s := NewState()
|
||||
s.MouseDownItemIdx = 1
|
||||
s.MouseDownLineIdx = 2
|
||||
s.MouseDownCol = 10
|
||||
s.DragItemIdx = 1
|
||||
s.DragLineIdx = 2
|
||||
s.DragCol = 20
|
||||
|
||||
r := s.GetRange()
|
||||
if r.IsEmpty() {
|
||||
t.Error("expected non-empty range")
|
||||
}
|
||||
if r.StartCol != 10 || r.EndCol != 20 {
|
||||
t.Errorf("expected cols 10-20, got %d-%d", r.StartCol, r.EndCol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeIsEmpty(t *testing.T) {
|
||||
// Same point
|
||||
r := Range{StartItemIdx: 0, StartLine: 0, StartCol: 5, EndItemIdx: 0, EndLine: 0, EndCol: 5}
|
||||
if !r.IsEmpty() {
|
||||
t.Error("expected same-point range to be empty")
|
||||
}
|
||||
|
||||
// Negative item idx
|
||||
r = Range{StartItemIdx: -1, EndItemIdx: -1}
|
||||
if !r.IsEmpty() {
|
||||
t.Error("expected negative item idx range to be empty")
|
||||
}
|
||||
|
||||
// Valid range
|
||||
r = Range{StartItemIdx: 0, StartLine: 0, StartCol: 0, EndItemIdx: 0, EndLine: 0, EndCol: 5}
|
||||
if r.IsEmpty() {
|
||||
t.Error("expected valid range to not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSelection(t *testing.T) {
|
||||
s := NewState()
|
||||
if s.HasSelection() {
|
||||
t.Error("new state should have no selection")
|
||||
}
|
||||
|
||||
// Set up a valid selection
|
||||
s.MouseDownItemIdx = 0
|
||||
s.MouseDownLineIdx = 0
|
||||
s.MouseDownCol = 0
|
||||
s.DragItemIdx = 0
|
||||
s.DragLineIdx = 0
|
||||
s.DragCol = 10
|
||||
if !s.HasSelection() {
|
||||
t.Error("expected selection to exist")
|
||||
}
|
||||
|
||||
// Same point = no selection
|
||||
s.DragCol = 0
|
||||
if s.HasSelection() {
|
||||
t.Error("same point should not be a selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLineInRange_SingleItem_SingleLine(t *testing.T) {
|
||||
r := Range{
|
||||
StartItemIdx: 1, StartLine: 2, StartCol: 5,
|
||||
EndItemIdx: 1, EndLine: 2, EndCol: 15,
|
||||
}
|
||||
|
||||
// Exact line
|
||||
ok, sc, ec := IsLineInRange(r, 1, 2)
|
||||
if !ok || sc != 5 || ec != 15 {
|
||||
t.Errorf("expected (true, 5, 15), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// Wrong line
|
||||
ok, _, _ = IsLineInRange(r, 1, 0)
|
||||
if ok {
|
||||
t.Error("line 0 should not be in range")
|
||||
}
|
||||
|
||||
// Wrong item
|
||||
ok, _, _ = IsLineInRange(r, 0, 2)
|
||||
if ok {
|
||||
t.Error("item 0 should not be in range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLineInRange_SingleItem_MultiLine(t *testing.T) {
|
||||
r := Range{
|
||||
StartItemIdx: 0, StartLine: 1, StartCol: 5,
|
||||
EndItemIdx: 0, EndLine: 4, EndCol: 10,
|
||||
}
|
||||
|
||||
// Start line
|
||||
ok, sc, ec := IsLineInRange(r, 0, 1)
|
||||
if !ok || sc != 5 || ec != -1 {
|
||||
t.Errorf("start line: expected (true, 5, -1), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// Middle line
|
||||
ok, sc, ec = IsLineInRange(r, 0, 2)
|
||||
if !ok || sc != -1 || ec != -1 {
|
||||
t.Errorf("middle line: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// End line
|
||||
ok, sc, ec = IsLineInRange(r, 0, 4)
|
||||
if !ok || sc != 0 || ec != 10 {
|
||||
t.Errorf("end line: expected (true, 0, 10), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLineInRange_MultiItem(t *testing.T) {
|
||||
r := Range{
|
||||
StartItemIdx: 0, StartLine: 3, StartCol: 5,
|
||||
EndItemIdx: 2, EndLine: 1, EndCol: 10,
|
||||
}
|
||||
|
||||
// First item, start line
|
||||
ok, sc, ec := IsLineInRange(r, 0, 3)
|
||||
if !ok || sc != 5 || ec != -1 {
|
||||
t.Errorf("first item start: expected (true, 5, -1), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// First item, line after start
|
||||
ok, sc, ec = IsLineInRange(r, 0, 5)
|
||||
if !ok || sc != -1 || ec != -1 {
|
||||
t.Errorf("first item after: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// Middle item, any line
|
||||
ok, sc, ec = IsLineInRange(r, 1, 0)
|
||||
if !ok || sc != -1 || ec != -1 {
|
||||
t.Errorf("middle item: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// Last item, end line
|
||||
ok, sc, ec = IsLineInRange(r, 2, 1)
|
||||
if !ok || sc != 0 || ec != 10 {
|
||||
t.Errorf("last item end: expected (true, 0, 10), got (%v, %d, %d)", ok, sc, ec)
|
||||
}
|
||||
|
||||
// Last item, line after end
|
||||
ok, _, _ = IsLineInRange(r, 2, 5)
|
||||
if ok {
|
||||
t.Error("line after end in last item should not be in range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindWordBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
col int
|
||||
wantStart int
|
||||
wantEnd int
|
||||
}{
|
||||
{
|
||||
name: "simple word",
|
||||
line: "hello world",
|
||||
col: 2,
|
||||
wantStart: 0,
|
||||
wantEnd: 5,
|
||||
},
|
||||
{
|
||||
name: "second word",
|
||||
line: "hello world",
|
||||
col: 7,
|
||||
wantStart: 6,
|
||||
wantEnd: 11,
|
||||
},
|
||||
{
|
||||
name: "on space",
|
||||
line: "hello world",
|
||||
col: 5,
|
||||
wantStart: 5,
|
||||
wantEnd: 5,
|
||||
},
|
||||
{
|
||||
name: "empty line",
|
||||
line: "",
|
||||
col: 0,
|
||||
wantStart: 0,
|
||||
wantEnd: 0,
|
||||
},
|
||||
{
|
||||
name: "negative col",
|
||||
line: "hello",
|
||||
col: -1,
|
||||
wantStart: 0,
|
||||
wantEnd: 0,
|
||||
},
|
||||
{
|
||||
name: "past end",
|
||||
line: "hello",
|
||||
col: 10,
|
||||
wantStart: 10,
|
||||
wantEnd: 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end := FindWordBoundaries(tt.line, tt.col)
|
||||
if start != tt.wantStart || end != tt.wantEnd {
|
||||
t.Errorf("FindWordBoundaries(%q, %d) = (%d, %d), want (%d, %d)",
|
||||
tt.line, tt.col, start, end, tt.wantStart, tt.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_PlainText(t *testing.T) {
|
||||
line := "Hello, World!"
|
||||
text := ExtractText(line, 0, 5)
|
||||
if text != "Hello" {
|
||||
t.Errorf("expected 'Hello', got %q", text)
|
||||
}
|
||||
|
||||
text = ExtractText(line, 7, 12)
|
||||
if text != "World" {
|
||||
t.Errorf("expected 'World', got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_FullLine(t *testing.T) {
|
||||
line := "Hello"
|
||||
text := ExtractText(line, -1, -1)
|
||||
if text != "Hello" {
|
||||
t.Errorf("expected 'Hello', got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_Empty(t *testing.T) {
|
||||
text := ExtractText("", 0, 5)
|
||||
if text != "" {
|
||||
t.Errorf("expected empty string, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_OutOfBounds(t *testing.T) {
|
||||
line := "Hi"
|
||||
text := ExtractText(line, 5, 10)
|
||||
if text != "" {
|
||||
t.Errorf("expected empty string for out of bounds, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightLine_PlainText(t *testing.T) {
|
||||
line := "Hello, World!"
|
||||
result := HighlightLine(line, 0, 5)
|
||||
// Should produce a non-empty result different from input (has ANSI codes)
|
||||
if result == "" {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
// Should still contain the text content
|
||||
if len(result) < len(line) {
|
||||
t.Error("result should be at least as long as input (ANSI codes add length)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightLine_Empty(t *testing.T) {
|
||||
result := HighlightLine("", 0, 5)
|
||||
if result != "" {
|
||||
t.Errorf("expected empty for empty input, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightLine_NoSelection(t *testing.T) {
|
||||
line := "Hello"
|
||||
result := HighlightLine(line, 3, 3)
|
||||
// Same startCol and endCol = no change
|
||||
if result != line {
|
||||
t.Errorf("expected no change for zero-width selection, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiClickDetection verifies the click counting logic.
|
||||
func TestMultiClickDetection(t *testing.T) {
|
||||
s := NewState()
|
||||
now := time.Now()
|
||||
|
||||
// First click
|
||||
s.LastClickTime = now
|
||||
s.LastClickX = 10
|
||||
s.LastClickY = 5
|
||||
s.ClickCount = 1
|
||||
|
||||
// Second click within threshold
|
||||
later := now.Add(200 * time.Millisecond)
|
||||
if later.Sub(s.LastClickTime) <= DoubleClickThreshold {
|
||||
if abs(10-s.LastClickX) <= ClickTolerance && abs(5-s.LastClickY) <= ClickTolerance {
|
||||
s.ClickCount++
|
||||
}
|
||||
}
|
||||
if s.ClickCount != 2 {
|
||||
t.Errorf("expected click count 2, got %d", s.ClickCount)
|
||||
}
|
||||
|
||||
// Third click
|
||||
s.LastClickTime = later
|
||||
later2 := later.Add(200 * time.Millisecond)
|
||||
if later2.Sub(s.LastClickTime) <= DoubleClickThreshold {
|
||||
if abs(10-s.LastClickX) <= ClickTolerance && abs(5-s.LastClickY) <= ClickTolerance {
|
||||
s.ClickCount++
|
||||
}
|
||||
}
|
||||
if s.ClickCount != 3 {
|
||||
t.Errorf("expected click count 3, got %d", s.ClickCount)
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// SessionSelectedMsg is sent when the user selects a session from the picker.
|
||||
@@ -158,12 +159,12 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
||||
if ss.cursor > 0 {
|
||||
ss.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
||||
if ss.cursor < len(ss.filtered)-1 {
|
||||
ss.cursor++
|
||||
}
|
||||
@@ -250,7 +251,7 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// View implements tea.Model.
|
||||
func (ss *SessionSelectorComponent) View() tea.View {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
w := ss.width
|
||||
var b strings.Builder
|
||||
|
||||
@@ -327,11 +328,6 @@ func (ss *SessionSelectorComponent) View() tea.View {
|
||||
|
||||
v := tea.NewView(b.String())
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -410,7 +406,7 @@ func removeByPath(sessions []session.SessionInfo, path string) []session.Session
|
||||
// renderEntry renders a single session line with right-aligned metadata.
|
||||
// Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?]
|
||||
func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
// ── Cursor indicator (2 chars) ───────────────────────────────
|
||||
cursorStr := " "
|
||||
|
||||
+27
-159
@@ -9,7 +9,9 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/indaco/herald"
|
||||
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
|
||||
@@ -31,7 +33,7 @@ func knightRiderFrames() []string {
|
||||
const numDots = 8
|
||||
const dot = "▪"
|
||||
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
bright := lipgloss.NewStyle().Foreground(theme.Primary)
|
||||
med := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
@@ -131,13 +133,13 @@ const (
|
||||
// alongside streaming text until the step completes and Reset() is called.
|
||||
//
|
||||
// Tool calls, tool results, user messages, and other non-streaming content
|
||||
// are printed immediately by the parent AppModel via tea.Println(). The
|
||||
// StreamComponent only handles the live streaming text and spinner display.
|
||||
// are added to the ScrollList by the parent AppModel. The StreamComponent
|
||||
// only handles the live streaming text and spinner display.
|
||||
//
|
||||
// Lifecycle is managed entirely by the parent AppModel:
|
||||
// - Parent calls Reset() between agent steps to clear state.
|
||||
// - Parent emits completed responses above the BT region via tea.Println()
|
||||
// then calls Reset(); StreamComponent never calls tea.Quit.
|
||||
// - Content is displayed via StreamingMessageItem in the ScrollList.
|
||||
// - StreamComponent never calls tea.Quit.
|
||||
//
|
||||
// Events handled:
|
||||
// - app.SpinnerEvent{Show:true} → start spinner tick loop
|
||||
@@ -196,23 +198,6 @@ type StreamComponent struct {
|
||||
// ticks from a previous step can be discarded.
|
||||
flushGeneration uint64
|
||||
|
||||
// renderCache holds the last rendered output string. Reused by View()
|
||||
// between flush ticks to avoid redundant markdown re-parsing.
|
||||
renderCache string
|
||||
|
||||
// renderDirty is true when committed content has changed since the
|
||||
// last render. Set on flush tick; cleared after render() rebuilds
|
||||
// the cache.
|
||||
renderDirty bool
|
||||
|
||||
// scrollbackFlushedLines is the number of lines from the top of the
|
||||
// rendered content that have already been emitted to the terminal
|
||||
// scrollback buffer. On each flush, lines that overflow the allocated
|
||||
// height and haven't been pushed yet are emitted via tea.Println so
|
||||
// they appear in the terminal's real scrollback (scrollable with the
|
||||
// terminal's own scroll mechanism).
|
||||
scrollbackFlushedLines int
|
||||
|
||||
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
|
||||
thinkingVisible bool
|
||||
|
||||
@@ -272,9 +257,6 @@ func (s *StreamComponent) SetHeight(h int) {
|
||||
}
|
||||
if s.height != h {
|
||||
s.height = h
|
||||
// Invalidate cache — height clamp affects output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,59 +275,23 @@ func (s *StreamComponent) Reset() {
|
||||
s.pendingReasoning.Reset()
|
||||
s.flushPending = false
|
||||
s.flushGeneration++
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
s.timestamp = time.Time{}
|
||||
s.reasoningStartTime = time.Time{}
|
||||
s.reasoningDuration = 0
|
||||
s.scrollbackFlushedLines = 0
|
||||
}
|
||||
|
||||
// ConsumeOverflow returns any lines from the rendered stream content that have
|
||||
// overflowed the allocated height and have not yet been pushed to the terminal
|
||||
// scrollback buffer. It advances the internal flushed-line pointer so
|
||||
// subsequent calls only return newly overflowed lines.
|
||||
//
|
||||
// Returns "" when there is no overflow or height is unconstrained (0).
|
||||
// The caller should emit the returned string via tea.Println so the content
|
||||
// appears in the terminal's real scrollback (not just discarded).
|
||||
// ConsumeOverflow is a no-op in alt screen mode. Overflow is handled by the
|
||||
// ScrollList viewport. Retained to satisfy streamComponentIface.
|
||||
func (s *StreamComponent) ConsumeOverflow() string {
|
||||
if s.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
content := s.render()
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
totalLines := len(lines)
|
||||
// Number of lines that overflow the viewable height.
|
||||
overflowLines := totalLines - s.height
|
||||
if overflowLines <= 0 {
|
||||
return ""
|
||||
}
|
||||
// How many overflow lines are new (not yet flushed to scrollback).
|
||||
newOverflow := overflowLines - s.scrollbackFlushedLines
|
||||
if newOverflow <= 0 {
|
||||
return ""
|
||||
}
|
||||
// The new overflow is lines [s.scrollbackFlushedLines .. overflowLines).
|
||||
start := s.scrollbackFlushedLines
|
||||
end := overflowLines
|
||||
s.scrollbackFlushedLines = overflowLines
|
||||
return strings.Join(lines[start:end], "\n")
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRenderedContent returns the rendered assistant message from the accumulated
|
||||
// streaming text. Returns empty string if no text has been accumulated. Used by
|
||||
// the parent AppModel to flush content via tea.Println() before resetting.
|
||||
// the parent AppModel to flush stream content before resetting.
|
||||
//
|
||||
// This commits any pending chunks first so the output includes all received
|
||||
// content, not just what has been flushed by the tick.
|
||||
//
|
||||
// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow
|
||||
// are skipped so that callers do not re-emit content that is already visible
|
||||
// in the terminal's real scrollback.
|
||||
func (s *StreamComponent) GetRenderedContent() string {
|
||||
// Commit any pending chunks so the final output is complete.
|
||||
s.commitPending()
|
||||
@@ -366,35 +312,21 @@ func (s *StreamComponent) GetRenderedContent() string {
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
fullContent := strings.Join(sections, "\n")
|
||||
|
||||
// Skip lines already emitted to the terminal scrollback via ConsumeOverflow
|
||||
// so the caller doesn't re-print content that is already there.
|
||||
if s.scrollbackFlushedLines > 0 {
|
||||
lines := strings.Split(fullContent, "\n")
|
||||
if s.scrollbackFlushedLines >= len(lines) {
|
||||
return "" // everything already in scrollback
|
||||
}
|
||||
return strings.Join(lines[s.scrollbackFlushedLines:], "\n")
|
||||
}
|
||||
|
||||
return fullContent
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
// commitPending moves any pending chunks to the committed content builders.
|
||||
// Called before reading content for scrollback output or on flush tick.
|
||||
// Called before reading content for output or on flush tick.
|
||||
func (s *StreamComponent) commitPending() {
|
||||
if s.pendingStream.Len() > 0 {
|
||||
// Strip ... tags that some models wrap reasoning in
|
||||
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
|
||||
s.streamContent.WriteString(cleanedText)
|
||||
s.pendingStream.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
if s.pendingReasoning.Len() > 0 {
|
||||
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
||||
s.pendingReasoning.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,9 +349,6 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if s.renderer != nil {
|
||||
s.renderer.SetWidth(s.width)
|
||||
}
|
||||
// Invalidate render cache — width change affects wrapping/styling.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
|
||||
case streamSpinnerTickMsg:
|
||||
// Only continue the tick loop if this tick belongs to the current
|
||||
@@ -472,6 +401,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, streamFlushTickCmd(s.flushGeneration)
|
||||
}
|
||||
|
||||
case app.ReasoningCompleteEvent:
|
||||
// Freeze reasoning duration when reasoning finishes (before text streaming starts).
|
||||
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
||||
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
// Flush any remaining pending reasoning content.
|
||||
if s.pendingReasoning.Len() > 0 {
|
||||
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
||||
s.pendingReasoning.Reset()
|
||||
}
|
||||
|
||||
case app.StreamChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
@@ -559,79 +499,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model. Renders the current stream region content.
|
||||
// View implements tea.Model. Returns an empty view since rendering is handled
|
||||
// by StreamingMessageItem in the ScrollList. Retained to satisfy tea.Model.
|
||||
func (s *StreamComponent) View() tea.View {
|
||||
fullContent := s.render()
|
||||
visibleContent := s.viewContent(fullContent)
|
||||
v := tea.NewView(visibleContent)
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Internal rendering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// render builds the full content string for the stream region. Uses a render
|
||||
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
|
||||
// is invalidated when committed content changes (flush tick), terminal width
|
||||
// changes, or height/thinking visibility changes.
|
||||
func (s *StreamComponent) render() string {
|
||||
if s.phase == streamPhaseIdle {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return cached render if committed content hasn't changed.
|
||||
if !s.renderDirty {
|
||||
return s.renderCache
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Render reasoning/thinking block above the main text if present.
|
||||
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
||||
sections = append(sections, s.renderReasoningBlock(reasoning))
|
||||
}
|
||||
|
||||
// Render streaming text only. The spinner is rendered in the status bar
|
||||
// by the parent so it never changes the stream region height.
|
||||
text := s.streamContent.String()
|
||||
if text != "" {
|
||||
sections = append(sections, s.renderStreamingText(text))
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
return ""
|
||||
}
|
||||
|
||||
content := strings.Join(sections, "\n")
|
||||
|
||||
// Cache FULL content without height clamping.
|
||||
// Height clamping is applied in View() for display only.
|
||||
s.renderCache = content
|
||||
s.renderDirty = false
|
||||
return content
|
||||
}
|
||||
|
||||
// viewContent returns the visible portion of content based on height constraint.
|
||||
// This is called by View() to get the slice that fits in the terminal.
|
||||
func (s *StreamComponent) viewContent(fullContent string) string {
|
||||
if s.height > 0 && fullContent != "" {
|
||||
lines := strings.Split(fullContent, "\n")
|
||||
if len(lines) > s.height {
|
||||
// Keep only the last h lines so the most recent output is visible.
|
||||
lines = lines[len(lines)-s.height:]
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
return fullContent
|
||||
return tea.NewView("")
|
||||
}
|
||||
|
||||
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
|
||||
@@ -692,9 +563,6 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
func (s *StreamComponent) SetThinkingVisible(visible bool) {
|
||||
if s.thinkingVisible != visible {
|
||||
s.thinkingVisible = visible
|
||||
// Invalidate cache — thinking visibility affects rendered output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package style
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -294,3 +294,28 @@ func ApplyGradient(text string, colorA, colorB color.Color) string {
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// KitBanner returns the KIT ASCII art title with KITT scanner lights,
|
||||
// rendered with a KITT red gradient.
|
||||
func KitBanner() string {
|
||||
kittDark := lipgloss.Color("#8B0000")
|
||||
kittBright := lipgloss.Color("#FF2200")
|
||||
lines := []string{
|
||||
" ██╗ ██╗ ██╗ ████████╗",
|
||||
" ██║ ██╔╝ ██║ ╚══██╔══╝",
|
||||
" █████╔╝ ██║ ██║",
|
||||
" ██╔═██╗ ██║ ██║",
|
||||
" ██║ ██╗ ██║ ██║",
|
||||
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
|
||||
"░░ ░░ ░░ ▒▒ ▒▒ ▓▓ ▓▓ ████ ▓▓ ▓▓ ▒▒ ▒▒ ░░ ░░ ░░",
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
result.WriteString(ApplyGradient(line, kittDark, kittBright))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package style
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -85,10 +85,10 @@ func GetMarkdownTypography() *herald.Typography {
|
||||
return ty
|
||||
}
|
||||
|
||||
// toMarkdown renders markdown content using herald-md.
|
||||
// ToMarkdown renders markdown content using herald-md.
|
||||
// The width parameter is currently unused as herald handles wrapping
|
||||
// based on terminal width internally.
|
||||
func toMarkdown(content string, width int) string {
|
||||
func ToMarkdown(content string, width int) string {
|
||||
ty := GetMarkdownTypography()
|
||||
rendered := heraldmd.Render(ty, []byte(content))
|
||||
return rendered
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package style
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/mark3labs/kit/internal/ui/prefs"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -410,10 +412,10 @@ func initThemeRegistry() {
|
||||
}
|
||||
|
||||
// 2. User themes from ~/.config/kit/themes/
|
||||
scanThemesDir(userThemesDir())
|
||||
scanThemesDir(UserThemesDir())
|
||||
|
||||
// 3. Project-local themes from .kit/themes/
|
||||
scanThemesDir(projectThemesDir())
|
||||
scanThemesDir(ProjectThemesDir())
|
||||
|
||||
sortRegistry()
|
||||
}
|
||||
@@ -461,7 +463,7 @@ func removeFromRegistry(name string) {
|
||||
}
|
||||
|
||||
// userThemesDir returns ~/.config/kit/themes, creating it if needed.
|
||||
func userThemesDir() string {
|
||||
func UserThemesDir() string {
|
||||
cfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -473,7 +475,7 @@ func userThemesDir() string {
|
||||
|
||||
// projectThemesDir returns .kit/themes/ relative to the working directory.
|
||||
// Returns "" if the directory doesn't exist (does NOT create it).
|
||||
func projectThemesDir() string {
|
||||
func ProjectThemesDir() string {
|
||||
dir := filepath.Join(".kit", "themes")
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
@@ -525,7 +527,7 @@ func ApplyTheme(name string) error {
|
||||
return err
|
||||
}
|
||||
SetTheme(t)
|
||||
_ = SaveThemePreference(name)
|
||||
_ = prefs.SaveThemePreference(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package style
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -83,17 +83,8 @@ func (t *ToolApprovalInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (t *ToolApprovalInput) View() tea.View {
|
||||
v := tea.NewView("")
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
|
||||
if t.done {
|
||||
v.Content = "we are done"
|
||||
return v
|
||||
return tea.NewView("we are done")
|
||||
}
|
||||
|
||||
containerStyle := lipgloss.NewStyle()
|
||||
@@ -145,6 +136,5 @@ func (t *ToolApprovalInput) View() tea.View {
|
||||
}
|
||||
view.WriteString(yesText + "/" + noText + "\n")
|
||||
|
||||
v.Content = containerStyle.Render(inputBoxStyle.Render(view.String()))
|
||||
return v
|
||||
return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String())))
|
||||
}
|
||||
|
||||
+277
-32
@@ -28,10 +28,10 @@ const (
|
||||
maxLsLines = 20 // lines for Ls directory listings
|
||||
)
|
||||
|
||||
// isShellTool reports if the tool name matches a shell-like tool (bash, grep, find, or
|
||||
// isShellTool reports if the tool name matches a shell-like tool (bash or
|
||||
// tools with "shell"/"command" in the name). Used by renderToolBody.
|
||||
func isShellTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "grep" || toolName == "find" ||
|
||||
return toolName == "bash" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command")
|
||||
}
|
||||
|
||||
@@ -55,8 +55,16 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
|
||||
if body := renderWriteBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "find":
|
||||
if body := renderFindBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "grep":
|
||||
if body := renderGrepBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case isShellTool(toolName):
|
||||
if body := renderBashBody(toolResult, width); body != "" {
|
||||
if body := renderBashBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "subagent":
|
||||
@@ -337,6 +345,148 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
|
||||
// Ls tool — simple list without gutter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderFindBody renders find output as a plain list with code background.
|
||||
// Similar to ls but with results-specific caption.
|
||||
func renderFindBody(toolResult string, width int) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
totalResults := len(lines)
|
||||
|
||||
// Truncate to maxLsLines for display
|
||||
var hiddenCount int
|
||||
if len(lines) > maxLsLines {
|
||||
hiddenCount = len(lines) - maxLsLines
|
||||
lines = lines[:maxLsLines]
|
||||
}
|
||||
|
||||
const lineIndent = " "
|
||||
codeWidth := max(width-len(lineIndent), 20)
|
||||
|
||||
theme := GetTheme()
|
||||
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
|
||||
|
||||
var rendered []string
|
||||
for _, line := range lines {
|
||||
// Truncate before styling to prevent wrapping.
|
||||
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
rendered = append(rendered, styled)
|
||||
}
|
||||
|
||||
content = strings.Join(rendered, "\n")
|
||||
|
||||
// Build caption with results info
|
||||
var captionParts []string
|
||||
if totalResults == 1 {
|
||||
captionParts = append(captionParts, "1 result")
|
||||
} else {
|
||||
captionParts = append(captionParts, fmt.Sprintf("%d results", totalResults))
|
||||
}
|
||||
if hiddenCount > 0 {
|
||||
captionParts = append(captionParts, fmt.Sprintf("%d more", hiddenCount))
|
||||
}
|
||||
|
||||
if len(captionParts) > 1 || hiddenCount > 0 {
|
||||
ty := herald.New(herald.WithTheme(herald.Theme{
|
||||
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
|
||||
FigureCaptionPosition: herald.CaptionBottom,
|
||||
}))
|
||||
caption := strings.Join(captionParts, " • ")
|
||||
result := ty.Figure(content, caption)
|
||||
|
||||
// Indent entire block (content + caption) to match other tools
|
||||
const blockIndent = " "
|
||||
resultLines := strings.Split(result, "\n")
|
||||
for i, line := range resultLines {
|
||||
resultLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(resultLines, "\n")
|
||||
}
|
||||
|
||||
// Single result with no truncation - just return indented content
|
||||
const blockIndent = " "
|
||||
contentLines := strings.Split(content, "\n")
|
||||
for i, line := range contentLines {
|
||||
contentLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(contentLines, "\n")
|
||||
}
|
||||
|
||||
// renderGrepBody renders grep output as a plain list with code background.
|
||||
// Similar to find but with match-specific caption terminology.
|
||||
func renderGrepBody(toolResult string, width int) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
totalMatches := len(lines)
|
||||
|
||||
// Truncate to maxLsLines for display
|
||||
var hiddenCount int
|
||||
if len(lines) > maxLsLines {
|
||||
hiddenCount = len(lines) - maxLsLines
|
||||
lines = lines[:maxLsLines]
|
||||
}
|
||||
|
||||
const lineIndent = " "
|
||||
codeWidth := max(width-len(lineIndent), 20)
|
||||
|
||||
theme := GetTheme()
|
||||
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
|
||||
|
||||
var rendered []string
|
||||
for _, line := range lines {
|
||||
// Truncate before styling to prevent wrapping.
|
||||
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
rendered = append(rendered, styled)
|
||||
}
|
||||
|
||||
content = strings.Join(rendered, "\n")
|
||||
|
||||
// Build caption with match info
|
||||
var captionParts []string
|
||||
if totalMatches == 1 {
|
||||
captionParts = append(captionParts, "1 match")
|
||||
} else {
|
||||
captionParts = append(captionParts, fmt.Sprintf("%d matches", totalMatches))
|
||||
}
|
||||
if hiddenCount > 0 {
|
||||
captionParts = append(captionParts, fmt.Sprintf("%d more", hiddenCount))
|
||||
}
|
||||
|
||||
if len(captionParts) > 1 || hiddenCount > 0 {
|
||||
ty := herald.New(herald.WithTheme(herald.Theme{
|
||||
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
|
||||
FigureCaptionPosition: herald.CaptionBottom,
|
||||
}))
|
||||
caption := strings.Join(captionParts, " • ")
|
||||
result := ty.Figure(content, caption)
|
||||
|
||||
// Indent entire block (content + caption) to match other tools
|
||||
const blockIndent = " "
|
||||
resultLines := strings.Split(result, "\n")
|
||||
for i, line := range resultLines {
|
||||
resultLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(resultLines, "\n")
|
||||
}
|
||||
|
||||
// Single match with no truncation - just return indented content
|
||||
const blockIndent = " "
|
||||
contentLines := strings.Split(content, "\n")
|
||||
for i, line := range contentLines {
|
||||
contentLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(contentLines, "\n")
|
||||
}
|
||||
|
||||
// renderLsBody renders ls output as a plain list with code background and no
|
||||
// line-number gutter.
|
||||
func renderLsBody(toolResult string, width int) string {
|
||||
@@ -354,28 +504,47 @@ func renderLsBody(toolResult string, width int) string {
|
||||
lines = lines[:maxLsLines]
|
||||
}
|
||||
|
||||
const indent = " "
|
||||
codeWidth := max(width-len(indent), 20)
|
||||
const lineIndent = " "
|
||||
codeWidth := max(width-len(lineIndent), 20)
|
||||
|
||||
theme := GetTheme()
|
||||
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
|
||||
|
||||
var result []string
|
||||
var rendered []string
|
||||
for _, line := range lines {
|
||||
// Truncate before styling to prevent wrapping.
|
||||
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
result = append(result, indent+styled)
|
||||
rendered = append(rendered, styled)
|
||||
}
|
||||
|
||||
content = strings.Join(rendered, "\n")
|
||||
|
||||
// Build caption with hidden entries info
|
||||
if hiddenCount > 0 {
|
||||
hint := fmt.Sprintf("...(%d more entries)", hiddenCount)
|
||||
hintContent := codeStyle.Width(codeWidth).
|
||||
Foreground(theme.Muted).Italic(true).Render(hint)
|
||||
result = append(result, indent+hintContent)
|
||||
ty := herald.New(herald.WithTheme(herald.Theme{
|
||||
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
|
||||
FigureCaptionPosition: herald.CaptionBottom,
|
||||
}))
|
||||
caption := fmt.Sprintf("%d more entries", hiddenCount)
|
||||
result := ty.Figure(content, caption)
|
||||
|
||||
// Indent entire block (content + caption) to match other tools
|
||||
const blockIndent = " "
|
||||
resultLines := strings.Split(result, "\n")
|
||||
for i, line := range resultLines {
|
||||
resultLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(resultLines, "\n")
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
// No caption - just return indented content
|
||||
const blockIndent = " "
|
||||
contentLines := strings.Split(content, "\n")
|
||||
for i, line := range contentLines {
|
||||
contentLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(contentLines, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -461,19 +630,50 @@ func renderReadBody(toolArgs, toolResult string, width int) string {
|
||||
)
|
||||
|
||||
// Render the code block
|
||||
result := ty.CodeBlock(codeContent, lang)
|
||||
codeBlock := ty.CodeBlock(codeContent, lang)
|
||||
|
||||
// Add truncation hint if needed
|
||||
// Herald's codeBlockWithLineNumbers() hardcodes PaddingTop(1) and
|
||||
// PaddingBottom(1), adding invisible blank lines with background color
|
||||
// above and below the code. These interfere with mouse selection
|
||||
// (off-by-one) because the padding line looks blank but occupies a
|
||||
// line index in the rendered item. Strip them since the Compose
|
||||
// separator above and Figure caption below already provide spacing.
|
||||
codeBlock = stripCodeBlockPadding(codeBlock)
|
||||
|
||||
// Parse total lines from footer if available (e.g., "[showing lines 1-100 of 407 total...]")
|
||||
totalLines := totalCodeLines
|
||||
for _, footer := range footerLines {
|
||||
if matches := regexp.MustCompile(`of (\d+) total`).FindStringSubmatch(footer); len(matches) > 1 {
|
||||
if t, _ := strconv.Atoi(matches[1]); t > totalLines {
|
||||
totalLines = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build caption with file metadata
|
||||
var captionParts []string
|
||||
if fileName != "" {
|
||||
captionParts = append(captionParts, filepath.Base(fileName))
|
||||
}
|
||||
if len(codeLines) > 0 {
|
||||
endLine := offset + len(codeLines) - 1
|
||||
captionParts = append(captionParts, fmt.Sprintf("lines %d-%d of %d", offset, endLine, totalLines))
|
||||
}
|
||||
if codeHiddenCount > 0 {
|
||||
hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount)
|
||||
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Italic(true).Render(hint)
|
||||
nextOffset := offset + len(codeLines)
|
||||
captionParts = append(captionParts, fmt.Sprintf("offset=%d to continue", nextOffset))
|
||||
}
|
||||
|
||||
// Add any footer lines
|
||||
if len(footerLines) > 0 {
|
||||
footer := strings.Join(footerLines, "\n")
|
||||
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Render(footer)
|
||||
caption := strings.Join(captionParts, " • ")
|
||||
|
||||
// Use Figure with caption below content (default behavior)
|
||||
// Apply theme to ensure caption is positioned below
|
||||
figTheme := herald.Theme{
|
||||
FigureCaption: lipgloss.NewStyle().Foreground(GetTheme().Muted),
|
||||
FigureCaptionPosition: herald.CaptionBottom,
|
||||
}
|
||||
tyFig := herald.New(herald.WithTheme(figTheme))
|
||||
result := tyFig.Figure(codeBlock, caption)
|
||||
|
||||
// Indent entire block to match Write/Edit tools (2 spaces)
|
||||
const blockIndent = " "
|
||||
@@ -582,7 +782,7 @@ func renderWriteBlock(content, fileName string, width int) string {
|
||||
|
||||
// renderBashBody renders bash output with per-line background and stderr
|
||||
// in error color.
|
||||
func renderBashBody(toolResult string, width int) string {
|
||||
func renderBashBody(toolArgs, toolResult string, width int) string {
|
||||
if strings.TrimSpace(toolResult) == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -609,6 +809,7 @@ func renderBashBody(toolResult string, width int) string {
|
||||
maxLineChars := lineWidth - 1
|
||||
|
||||
var rendered []string
|
||||
exitCode := -1 // -1 means not found
|
||||
inStderr := false
|
||||
for _, line := range lines {
|
||||
line = truncateLine(line, maxLineChars)
|
||||
@@ -617,30 +818,55 @@ func renderBashBody(toolResult string, width int) string {
|
||||
inStderr = true
|
||||
continue
|
||||
}
|
||||
// Exit code line
|
||||
// Exit code line - extract it for caption
|
||||
if strings.HasPrefix(line, "Exit code:") {
|
||||
styled := stderrStyle.Width(width - len(lineIndent)).Render(line)
|
||||
rendered = append(rendered, lineIndent+styled)
|
||||
continue
|
||||
_, _ = fmt.Sscanf(line, "Exit code: %d", &exitCode)
|
||||
continue // Don't render exit code inline, it goes in caption
|
||||
}
|
||||
|
||||
if inStderr {
|
||||
styled := stderrStyle.Width(width - len(lineIndent)).Render(line)
|
||||
rendered = append(rendered, lineIndent+styled)
|
||||
rendered = append(rendered, styled)
|
||||
} else {
|
||||
styled := outputStyle.Width(width - len(lineIndent)).Render(line)
|
||||
rendered = append(rendered, lineIndent+styled)
|
||||
rendered = append(rendered, styled)
|
||||
}
|
||||
}
|
||||
|
||||
// Build caption with status info
|
||||
var captionParts []string
|
||||
if hiddenCount > 0 {
|
||||
truncMsg := fmt.Sprintf("...(%d more lines)", hiddenCount)
|
||||
hint := outputStyle.Width(width - len(lineIndent)).
|
||||
Foreground(theme.Muted).Italic(true).Render(truncMsg)
|
||||
rendered = append(rendered, lineIndent+hint)
|
||||
captionParts = append(captionParts, fmt.Sprintf("%d more lines", hiddenCount))
|
||||
}
|
||||
if exitCode >= 0 {
|
||||
captionParts = append(captionParts, fmt.Sprintf("exit code %d", exitCode))
|
||||
}
|
||||
|
||||
return strings.Join(rendered, "\n")
|
||||
content := strings.Join(rendered, "\n")
|
||||
if len(captionParts) > 0 {
|
||||
ty := herald.New(herald.WithTheme(herald.Theme{
|
||||
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
|
||||
FigureCaptionPosition: herald.CaptionBottom,
|
||||
}))
|
||||
caption := strings.Join(captionParts, " • ")
|
||||
result := ty.Figure(content, caption)
|
||||
|
||||
// Indent entire block (content + caption) to match other tools
|
||||
const blockIndent = " "
|
||||
lines := strings.Split(result, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// No caption - just return indented content
|
||||
const blockIndent = " "
|
||||
contentLines := strings.Split(content, "\n")
|
||||
for i, line := range contentLines {
|
||||
contentLines[i] = blockIndent + line
|
||||
}
|
||||
return strings.Join(contentLines, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -724,6 +950,25 @@ func padRight(s string, width int) string {
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
||||
// stripCodeBlockPadding removes the top and bottom padding lines that herald's
|
||||
// codeBlockWithLineNumbers() hardcodes via PaddingTop(1)/PaddingBottom(1).
|
||||
// These padding lines are blank lines with background color that look invisible
|
||||
// but occupy line indices, causing mouse selection to be off by one row.
|
||||
func stripCodeBlockPadding(block string) string {
|
||||
lines := strings.Split(block, "\n")
|
||||
if len(lines) < 3 {
|
||||
return block
|
||||
}
|
||||
// The first and last lines are padding (blank with bg color).
|
||||
// Strip them only if they contain no visible text.
|
||||
first := xansi.Strip(lines[0])
|
||||
last := xansi.Strip(lines[len(lines)-1])
|
||||
if strings.TrimSpace(first) == "" && strings.TrimSpace(last) == "" {
|
||||
return strings.Join(lines[1:len(lines)-1], "\n")
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
// truncateLine truncates a line to maxWidth visual characters, adding "…"
|
||||
// if truncated. This is ANSI-aware: escape codes are preserved and wide
|
||||
// characters are measured correctly.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
)
|
||||
|
||||
// TreeFilterMode controls which entries are visible in the tree selector.
|
||||
@@ -88,6 +89,28 @@ func NewTreeSelector(tm *session.TreeManager, width, height int) *TreeSelectorCo
|
||||
return ts
|
||||
}
|
||||
|
||||
// NewTreeSelectorForFork creates a tree selector for the /fork command.
|
||||
// It shows only user messages (flat list) matching Pi's fork behavior.
|
||||
func NewTreeSelectorForFork(tm *session.TreeManager, width, height int) *TreeSelectorComponent {
|
||||
ts := &TreeSelectorComponent{
|
||||
tm: tm,
|
||||
filter: TreeFilterUserOnly,
|
||||
leafID: tm.GetLeafID(),
|
||||
width: width,
|
||||
height: height,
|
||||
active: true,
|
||||
}
|
||||
ts.rebuildFlatList()
|
||||
// Position cursor at the last user message before the leaf.
|
||||
for i := len(ts.flatNodes) - 1; i >= 0; i-- {
|
||||
if ts.isUserMessage(ts.flatNodes[i].Entry) {
|
||||
ts.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (ts *TreeSelectorComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
@@ -103,12 +126,12 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
||||
if ts.cursor > 0 {
|
||||
ts.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
||||
if ts.cursor < len(ts.flatNodes)-1 {
|
||||
ts.cursor++
|
||||
}
|
||||
@@ -138,7 +161,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
ts.selectedID = ts.flatNodes[ts.cursor].ID
|
||||
ts.active = false
|
||||
return ts, func() tea.Msg {
|
||||
return TreeNodeSelectedMsg{
|
||||
return core.TreeNodeSelectedMsg{
|
||||
ID: ts.selectedID,
|
||||
Entry: ts.flatNodes[ts.cursor].Entry,
|
||||
IsUser: ts.isUserMessage(ts.flatNodes[ts.cursor].Entry),
|
||||
@@ -155,7 +178,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
ts.cancelled = true
|
||||
ts.active = false
|
||||
return ts, func() tea.Msg {
|
||||
return TreeCancelledMsg{}
|
||||
return core.TreeCancelledMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,11 +290,6 @@ func (ts *TreeSelectorComponent) View() tea.View {
|
||||
|
||||
v := tea.NewView(b.String())
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
v.ReportFocus = true
|
||||
v.KeyboardEnhancements = tea.KeyboardEnhancements{
|
||||
ReportEventTypes: true,
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mark3labs/kit",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@mark3labs/kit",
|
||||
"version": "0.0.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"kit": "bin/kit"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ func Init(api ext.API) {
|
||||
footer := harness.Context().GetFooter()
|
||||
if footer == nil {
|
||||
t.Fatal("expected footer to be set")
|
||||
return
|
||||
}
|
||||
if footer.Content.Text != "Status: OK" {
|
||||
t.Errorf("expected footer text 'Status: OK', got %q", footer.Content.Text)
|
||||
@@ -258,6 +259,7 @@ func Init(api ext.API) {
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Block {
|
||||
|
||||
@@ -39,6 +39,9 @@ const (
|
||||
EventCompaction EventType = "compaction"
|
||||
// EventReasoningDelta fires for each streaming reasoning/thinking chunk.
|
||||
EventReasoningDelta EventType = "reasoning_delta"
|
||||
// EventReasoningComplete fires when reasoning/thinking is finished,
|
||||
// after the last reasoning token has been processed.
|
||||
EventReasoningComplete EventType = "reasoning_complete"
|
||||
// EventToolOutput fires when a tool produces streaming output chunks.
|
||||
EventToolOutput EventType = "tool_output"
|
||||
EventStepUsage EventType = "step_usage"
|
||||
@@ -149,6 +152,13 @@ type ReasoningDeltaEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e ReasoningDeltaEvent) EventType() EventType { return EventReasoningDelta }
|
||||
|
||||
// ReasoningCompleteEvent fires when reasoning/thinking is finished, after the
|
||||
// last reasoning token has been processed.
|
||||
type ReasoningCompleteEvent struct{}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e ReasoningCompleteEvent) EventType() EventType { return EventReasoningComplete }
|
||||
|
||||
// ToolOutputEvent fires when a tool produces streaming output chunks (e.g., bash output).
|
||||
type ToolOutputEvent struct {
|
||||
ToolCallID string
|
||||
|
||||
@@ -177,6 +177,7 @@ func TestEventTypes(t *testing.T) {
|
||||
{ResponseEvent{}, EventResponse},
|
||||
{CompactionEvent{}, EventCompaction},
|
||||
{ReasoningDeltaEvent{}, EventReasoningDelta},
|
||||
{ReasoningCompleteEvent{}, EventReasoningComplete},
|
||||
{ToolOutputEvent{}, EventToolOutput},
|
||||
{StepUsageEvent{}, EventStepUsage},
|
||||
{SteerConsumedEvent{}, EventSteerConsumed},
|
||||
@@ -224,6 +225,7 @@ func TestEventOrdering(t *testing.T) {
|
||||
EventMessageStart,
|
||||
EventMessageUpdate,
|
||||
EventReasoningDelta,
|
||||
EventReasoningComplete,
|
||||
EventToolOutput,
|
||||
EventToolCall,
|
||||
EventToolExecutionStart,
|
||||
@@ -242,6 +244,7 @@ func TestEventOrdering(t *testing.T) {
|
||||
bus.emit(MessageStartEvent{})
|
||||
bus.emit(MessageUpdateEvent{Chunk: "hello"})
|
||||
bus.emit(ReasoningDeltaEvent{Delta: "thinking..."})
|
||||
bus.emit(ReasoningCompleteEvent{})
|
||||
bus.emit(ToolOutputEvent{ToolName: "bash", Chunk: "output"})
|
||||
bus.emit(ToolCallEvent{ToolName: "bash"})
|
||||
bus.emit(ToolExecutionStartEvent{ToolName: "bash"})
|
||||
|
||||
+154
-91
@@ -268,8 +268,8 @@ func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
|
||||
}
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
|
||||
// commands, renderers, and shortcuts update immediately. Extension-defined
|
||||
// tools are NOT updated (they are baked into the agent at creation time).
|
||||
// commands, renderers, shortcuts, and extension-defined tools all update
|
||||
// immediately.
|
||||
func (m *Kit) ReloadExtensions() error {
|
||||
if m.extRunner == nil {
|
||||
return fmt.Errorf("no extensions loaded")
|
||||
@@ -290,6 +290,12 @@ func (m *Kit) ReloadExtensions() error {
|
||||
// Swap extensions on the runner (clears dynamic state).
|
||||
m.extRunner.Reload(loaded)
|
||||
|
||||
// Update extension tools on the agent so the LLM sees changes.
|
||||
if m.agent != nil {
|
||||
extTools := extensions.ExtensionToolsAsFantasy(m.extRunner.RegisteredTools(), m.extRunner)
|
||||
m.agent.SetExtraTools(extTools)
|
||||
}
|
||||
|
||||
// Re-set context and emit SessionStart.
|
||||
ctx := m.extRunner.GetContext()
|
||||
m.extRunner.SetContext(ctx)
|
||||
@@ -499,85 +505,125 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
opts = &Options{}
|
||||
}
|
||||
|
||||
viperInitMu.Lock()
|
||||
defer viperInitMu.Unlock()
|
||||
// All viper writes (SetSDKDefaults, InitConfig, Set calls, system-prompt
|
||||
// composition) happen under viperInitMu. We also call BuildProviderConfig
|
||||
// here — it's fast (just reads) — so we can capture the full config
|
||||
// snapshot before releasing the lock. The expensive work (MCP loading,
|
||||
// provider creation, session init) then runs outside the lock, allowing
|
||||
// parallel subagent spawns to proceed concurrently.
|
||||
var (
|
||||
providerConfig *models.ProviderConfig
|
||||
modelString string
|
||||
cwd string
|
||||
contextFiles []*ContextFile
|
||||
loadedSkills []*Skill
|
||||
mcpConfig *config.Config
|
||||
debug bool
|
||||
noExtensions bool
|
||||
maxSteps int
|
||||
streaming bool
|
||||
)
|
||||
|
||||
// Set CLI-equivalent defaults for viper. When used as an SDK (without
|
||||
// cobra), these defaults are not registered via flag bindings.
|
||||
setSDKDefaults()
|
||||
if err := func() error {
|
||||
viperInitMu.Lock()
|
||||
defer viperInitMu.Unlock()
|
||||
|
||||
// Initialize config (loads config files and env vars).
|
||||
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
|
||||
// Check if model is already set, which indicates config was loaded.
|
||||
if viper.GetString("model") == "" {
|
||||
if err := InitConfig(opts.ConfigFile, false); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize config: %w", err)
|
||||
}
|
||||
}
|
||||
// Set CLI-equivalent defaults for viper. When used as an SDK (without
|
||||
// cobra), these defaults are not registered via flag bindings.
|
||||
setSDKDefaults()
|
||||
|
||||
// Handle CLI debug mode.
|
||||
if opts.Debug {
|
||||
viper.Set("debug", true)
|
||||
}
|
||||
|
||||
// Override viper settings with options.
|
||||
if opts.Model != "" {
|
||||
viper.Set("model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
viper.Set("system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.MaxSteps > 0 {
|
||||
viper.Set("max-steps", opts.MaxSteps)
|
||||
}
|
||||
viper.Set("stream", opts.Streaming)
|
||||
|
||||
// Resolve working directory for context/skill discovery.
|
||||
cwd := opts.SessionDir
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
// Load context files (AGENTS.md) from the project root.
|
||||
contextFiles := loadContextFiles(cwd)
|
||||
|
||||
// Load skills — either from explicit paths or via auto-discovery.
|
||||
loadedSkills, err := loadSkills(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load skills: %w", err)
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
// AGENTS.md context + skills metadata + date/cwd.
|
||||
{
|
||||
basePrompt := viper.GetString("system-prompt")
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
|
||||
// Inject AGENTS.md content as project context.
|
||||
for _, cf := range contextFiles {
|
||||
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
|
||||
// Initialize config (loads config files and env vars).
|
||||
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
|
||||
// Check if model is already set, which indicates config was loaded.
|
||||
if viper.GetString("model") == "" {
|
||||
if err := InitConfig(opts.ConfigFile, false); err != nil {
|
||||
return fmt.Errorf("failed to initialize config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject skills metadata (name + description + location).
|
||||
if len(loadedSkills) > 0 {
|
||||
pb.WithSkills(loadedSkills)
|
||||
// Handle CLI debug mode.
|
||||
if opts.Debug {
|
||||
viper.Set("debug", true)
|
||||
}
|
||||
|
||||
// Append current date/time and working directory.
|
||||
pb.WithSection("", fmt.Sprintf(
|
||||
"Current date and time: %s\nCurrent working directory: %s",
|
||||
time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd,
|
||||
))
|
||||
// Override viper settings with options.
|
||||
if opts.Model != "" {
|
||||
viper.Set("model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
viper.Set("system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.MaxSteps > 0 {
|
||||
viper.Set("max-steps", opts.MaxSteps)
|
||||
}
|
||||
viper.Set("stream", opts.Streaming)
|
||||
|
||||
viper.Set("system-prompt", pb.Build())
|
||||
// Resolve working directory for context/skill discovery.
|
||||
cwd = opts.SessionDir
|
||||
if cwd == "" {
|
||||
cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
// Load context files (AGENTS.md) from the project root.
|
||||
contextFiles = loadContextFiles(cwd)
|
||||
|
||||
// Load skills — either from explicit paths or via auto-discovery.
|
||||
var err error
|
||||
loadedSkills, err = loadSkills(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load skills: %w", err)
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
// AGENTS.md context + skills metadata + date/cwd.
|
||||
{
|
||||
basePrompt := viper.GetString("system-prompt")
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
|
||||
// Inject AGENTS.md content as project context.
|
||||
for _, cf := range contextFiles {
|
||||
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
|
||||
}
|
||||
|
||||
// Inject skills metadata (name + description + location).
|
||||
if len(loadedSkills) > 0 {
|
||||
pb.WithSkills(loadedSkills)
|
||||
}
|
||||
|
||||
// Append current date/time and working directory.
|
||||
pb.WithSection("", fmt.Sprintf(
|
||||
"Current date and time: %s\nCurrent working directory: %s",
|
||||
time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd,
|
||||
))
|
||||
|
||||
viper.Set("system-prompt", pb.Build())
|
||||
}
|
||||
|
||||
// Snapshot all viper-derived values now, while the lock is held.
|
||||
// BuildProviderConfig is fast (pure reads), so we do it here.
|
||||
var pcErr error
|
||||
providerConfig, _, pcErr = kitsetup.BuildProviderConfig()
|
||||
if pcErr != nil {
|
||||
return fmt.Errorf("failed to build provider config: %w", pcErr)
|
||||
}
|
||||
modelString = viper.GetString("model")
|
||||
debug = viper.GetBool("debug")
|
||||
noExtensions = viper.GetBool("no-extensions")
|
||||
maxSteps = viper.GetInt("max-steps")
|
||||
streaming = viper.GetBool("stream")
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ---- viperInitMu released — heavy I/O below runs concurrently ----
|
||||
|
||||
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
|
||||
var mcpConfig *config.Config
|
||||
if opts.CLI != nil {
|
||||
if opts.CLI != nil && opts.CLI.MCPConfig != nil {
|
||||
mcpConfig = opts.CLI.MCPConfig
|
||||
}
|
||||
if mcpConfig == nil {
|
||||
var err error
|
||||
mcpConfig, err = config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load MCP config: %w", err)
|
||||
@@ -595,12 +641,19 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
|
||||
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
// Pass the pre-built ProviderConfig and scalar viper snapshots so
|
||||
// SetupAgent doesn't need to re-read viper (which would require the lock).
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
ProviderConfig: providerConfig,
|
||||
Debug: debug,
|
||||
NoExtensions: noExtensions,
|
||||
MaxSteps: maxSteps,
|
||||
StreamingEnabled: streaming,
|
||||
}
|
||||
if opts.CLI != nil {
|
||||
setupOpts.ShowSpinner = opts.CLI.ShowSpinner
|
||||
@@ -624,7 +677,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
k := &Kit{
|
||||
agent: agentResult.Agent,
|
||||
treeSession: treeSession,
|
||||
modelString: viper.GetString("model"),
|
||||
modelString: modelString,
|
||||
events: newEventBus(),
|
||||
autoCompact: opts.AutoCompact,
|
||||
compactionOpts: opts.CompactionOptions,
|
||||
@@ -904,6 +957,16 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
// Pre-flight check: if the incoming context is already dead, don't
|
||||
// waste time attempting init. This catches the case where the parent
|
||||
// generation loop's context was cancelled (e.g. user ESC, step cancel)
|
||||
// between when the LLM requested the subagent tool and when this code
|
||||
// runs. We replace it with a fresh context carrying only the timeout,
|
||||
// since the subagent should be independently bounded.
|
||||
if ctx.Err() != nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -920,6 +983,17 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
}
|
||||
}
|
||||
|
||||
// Early validation: check model format and provider before doing any
|
||||
// expensive work (MCP init, system prompt composition, etc.). This
|
||||
// gives the calling agent immediate feedback it can act on — e.g.
|
||||
// correcting a typo — instead of waiting for a full Kit.New() cycle
|
||||
// that silently falls back to the parent model.
|
||||
if model != m.modelString {
|
||||
if err := models.GetGlobalRegistry().ValidateModelString(model); err != nil {
|
||||
return nil, fmt.Errorf("invalid subagent model %q: %w", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default system prompt.
|
||||
systemPrompt := cfg.SystemPrompt
|
||||
if systemPrompt == "" {
|
||||
@@ -932,9 +1006,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
tools = SubagentTools()
|
||||
}
|
||||
|
||||
// Create child Kit instance. If the requested model fails (bad name,
|
||||
// unsupported provider, etc.), fall back to the parent's model so the
|
||||
// agent gets a useful error message instead of a hard failure.
|
||||
// Create child Kit instance.
|
||||
childOpts := &Options{
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
@@ -943,20 +1015,8 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
Quiet: true,
|
||||
}
|
||||
child, err := New(ctx, childOpts)
|
||||
if err != nil && model != m.modelString {
|
||||
// Model-specific failure — retry with parent's model.
|
||||
childOpts.Model = m.modelString
|
||||
child, err = New(ctx, childOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create subagent: %w", err)
|
||||
}
|
||||
// Prepend a note so the agent knows which model is actually running.
|
||||
cfg.Prompt = fmt.Sprintf(
|
||||
"[Note: requested model %q was not available, using %s instead.]\n\n%s",
|
||||
model, m.modelString, cfg.Prompt,
|
||||
)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to create subagent: %w", err)
|
||||
if err != nil {
|
||||
return &SubagentResult{Elapsed: time.Since(start)}, fmt.Errorf("failed to create subagent: %w", err)
|
||||
}
|
||||
defer func() { _ = child.Close() }()
|
||||
|
||||
@@ -970,7 +1030,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return &SubagentResult{Elapsed: elapsed}, err
|
||||
}
|
||||
|
||||
subResult := &SubagentResult{
|
||||
@@ -1099,6 +1159,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
func(delta string) {
|
||||
m.events.emit(ReasoningDeltaEvent{Delta: delta})
|
||||
},
|
||||
func() {
|
||||
m.events.emit(ReasoningCompleteEvent{})
|
||||
},
|
||||
func(toolCallID, toolName, chunk string, isStderr bool) {
|
||||
// Emit tool output chunk event for streaming bash output
|
||||
m.events.emit(ToolOutputEvent{
|
||||
|
||||
@@ -91,7 +91,11 @@ api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
// e.StopReason string — "error" (on failure), "completed" (when LLM returns
|
||||
// empty stop reason), or the raw LLM provider value passed through
|
||||
// (e.g. "stop", "end_turn", "max_tokens", "tool_use").
|
||||
// To detect errors, check e.StopReason == "error".
|
||||
// Do NOT compare against "completed" for success — instead check != "error".
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
Generated
+6757
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ These commands are available inside the Kit TUI during an interactive session:
|
||||
| `/usage` | Show token usage |
|
||||
| `/reset-usage` | Reset usage statistics |
|
||||
| `/tree` | Navigate session tree |
|
||||
| `/fork` | Branch from an earlier message |
|
||||
| `/fork` | Fork to new session from an earlier message |
|
||||
| `/new` | Start a new session (creates new session file) |
|
||||
| `/name [name]` | Set or show session display name |
|
||||
| `/resume` | Open session picker to switch sessions (alias: `/r`) |
|
||||
|
||||
@@ -80,7 +80,7 @@ These slash commands are available during an interactive session:
|
||||
| `/import <path>` | Import and switch to a session from a JSONL file |
|
||||
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
|
||||
| `/tree` | Navigate the session tree |
|
||||
| `/fork` | Branch from an earlier message |
|
||||
| `/fork` | Fork to new session from an earlier message (creates new session file) |
|
||||
| `/new` | Start a new session (creates new session file) |
|
||||
|
||||
## Ephemeral mode
|
||||
|
||||
Reference in New Issue
Block a user