mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-16 04:26:04 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -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
|
||||
|
||||
+45
-77
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1647,7 +1628,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) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1788,7 +1769,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, 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 +1780,42 @@ 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,
|
||||
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.
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -127,7 +134,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
|
||||
|
||||
+16
-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 == "" {
|
||||
@@ -397,7 +362,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 +370,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 +402,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())
|
||||
}
|
||||
|
||||
+282
-307
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
// ModelEntry holds display metadata for a single model in the selector.
|
||||
@@ -115,12 +116,12 @@ func (ms *ModelSelectorComponent) 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 ms.cursor > 0 {
|
||||
ms.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
||||
if ms.cursor < len(ms.filtered)-1 {
|
||||
ms.cursor++
|
||||
}
|
||||
@@ -188,7 +189,7 @@ func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// View implements tea.Model.
|
||||
func (ms *ModelSelectorComponent) View() tea.View {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
@@ -283,11 +284,6 @@ func (ms *ModelSelectorComponent) 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
|
||||
}
|
||||
|
||||
@@ -400,7 +396,7 @@ func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry
|
||||
}
|
||||
|
||||
func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string {
|
||||
theme := GetTheme()
|
||||
theme := style.GetTheme()
|
||||
modelStr := entry.ModelID
|
||||
providerStr := fmt.Sprintf("[%s]", entry.Provider)
|
||||
|
||||
|
||||
+98
-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,22 @@ 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")
|
||||
}
|
||||
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 +684,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 +703,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 +795,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 +877,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")
|
||||
|
||||
|
||||
@@ -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,126 @@
|
||||
// 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
|
||||
var labelText string
|
||||
if duration > 0 {
|
||||
var durationStr string
|
||||
if duration < 1000 {
|
||||
durationStr = fmt.Sprintf("%dms", duration)
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", float64(duration)/1000)
|
||||
}
|
||||
labelText = "Thought for " + durationStr
|
||||
} else {
|
||||
labelText = "Thought"
|
||||
}
|
||||
|
||||
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(labelText)
|
||||
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 := " "
|
||||
|
||||
+16
-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
|
||||
@@ -559,79 +488,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 +552,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())))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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