Compare commits

..

28 Commits

Author SHA1 Message Date
Ed Zynda 9d38349091 fix: resolve all golangci-lint issues
- Use max() instead of if statement for min value
- Use strings.SplitSeq for efficient iteration
- Use range over int instead of explicit loop counter
- Remove unused functions:
  - InputComponent.renderPopup()
  - AppModel.renderStream()
  - AppModel.renderStreamingBashOutput()
  - AppModel.printCompactResult()
2026-03-31 17:49:25 +03:00
Ed Zynda fec8bac800 refactor: remove fallback from flushStreamContent
StreamingMessageItem must exist when flushing - no fallbacks.
2026-03-31 17:45:35 +03:00
Ed Zynda e76f5f3d45 fix: prevent duplicate text when flushing streaming content before tool calls
flushStreamContent() was creating a new StyledMessageItem when tool calls
started, but we already had a StreamingMessageItem with the same content.

Now we:
- Mark the existing StreamingMessageItem as complete
- Only create a new message as fallback if no streaming item exists

This fixes text duplication when assistant messages precede tool calls.
2026-03-31 17:43:50 +03:00
Ed Zynda 1ad493c5c7 feat: cap streaming bash output height and replace with tool result
- Limit streaming bash output to 20 lines max during live display
- Remove streaming bash item when tool completes
- Replace with truncated tool result block
- Expand background color to full terminal width with proper indentation
- Matches renderBashBody styling (lineIndent + width)

This prevents long-running commands from growing the UI forever while
still showing live output up to a reasonable height.
2026-03-31 17:42:32 +03:00
Ed Zynda ea6ddc8792 feat: integrate streaming bash output into ScrollList
- Add StreamingBashOutputItem to message_items.go
- Update ToolOutputEvent handler to append chunks to bash item in ScrollList
- Remove old renderStreamingBashOutput() that broke layout
- Bash output now streams inline with messages instead of separate section
- Auto-scrolls to bottom during streaming
- Marks bash item complete on ToolResultEvent

Fixes layout breaking when bash commands produce streaming output.
2026-03-31 17:38:03 +03:00
Ed Zynda 6d4e8bcec5 feat: add streaming support for compaction summaries
- Add StreamCallback parameter to compaction.Compact() for streaming text deltas
- Update generateSummary() to use fantasy.Agent.Stream() when callback provided
- Fix compactSplitTurn() to stream both history and turn prefix summaries
- Add SDK event subscription in CompactConversation() goroutine
- Update UI to handle streaming compaction like regular assistant messages
- Compaction summaries now stream word-by-word instead of appearing all at once

Fixes issue where compaction would show incomplete context (e.g. only 'nce')
by ensuring both history summary and turn prefix are streamed to the UI.
2026-03-31 17:33:51 +03:00
Ed Zynda e2ed345280 fix: center slash command popup overlay to prevent bottom overflow
- Move popup rendering from inline (below input) to centered overlay
- Add RenderPopupCentered() method to InputComponent
- Implement overlayContent() helper for line-by-line merging
- Popup now appears in center of screen above all content
- Prevents overflow issues when typing / at bottom of terminal
2026-03-31 16:45:57 +03:00
Ed Zynda e542eb797e fix: freeze reasoning duration counter on transition to assistant text
- Detect role transition in appendStreamingChunk (reasoning → assistant)
- Mark reasoning StreamingMessageItem as complete when assistant text starts
- Duration counter now freezes immediately when reasoning ends
- Add live duration counter that updates during reasoning streaming
- Store startTime and finalDuration for proper counter behavior
2026-03-31 16:40:41 +03:00
Ed Zynda e631fc1b17 feat: add live streaming text to ScrollList viewport
- Create StreamingMessageItem that accumulates chunks and re-renders
- Update StreamChunkEvent/ReasoningChunkEvent to append to StreamingMessageItem
- Enable live streaming display within ScrollList (iteratr-style)
- Mark streaming items as complete on ResponseCompleteEvent
- Reasoning and assistant text now stream in real-time in the viewport
2026-03-31 16:35:43 +03:00
Ed Zynda 290c5a4774 chore: disable select/copy functionality but keep plumbing
Disable the mouse selection and keyboard copy features while keeping
all the supporting code infrastructure:

- Comment out MouseClickMsg, MouseMotionMsg, MouseReleaseMsg handlers
- Comment out keyboard shortcuts (c/y keys) for copying
- Keep all ScrollList selection tracking code
- Keep clipboard utilities (clipboard.go)
- Keep highlighting functions in scrolllist.go

This allows the features to be easily re-enabled later while keeping
the codebase clean for now.
2026-03-31 16:29:01 +03:00
Ed Zynda 287d60c31e feat: add visual selection highlighting with theme colors
Implement visual feedback for text selection in the scrollback:

- Add isLineInSelection() to check if a line is within the current selection
- Add applyHighlight() using the theme's Highlight color for selected lines
- Add applyFocusIndicator() using MutedBorder for focused items
- Update View() to apply highlighting during rendering
- Add getItemAndLineAtY() for precise mouse position tracking
- Track both item index and line index within item for selection

Selection highlighting uses the user's selected theme colors for
consistent visual feedback across all themes.
2026-03-31 16:23:46 +03:00
Ed Zynda 3d45d98895 feat: add crush-style copy+paste support
Implement mouse selection and keyboard copy functionality following
crush's patterns:

- Add clipboard.go with dual-write clipboard support (OSC 52 + system)
- Add CopySelection tracking to ScrollList for text selection
- Implement HandleMouseDown/HandleMouseDrag/HandleMouseUp methods
- Add keyboard shortcuts (c/y) for copying messages
- Mouse click+drag to select text, auto-copy on release
- Toast notifications for copy feedback

Note: Full text extraction from selection requires additional work to
properly extract raw text from styled message content.
2026-03-31 16:19:58 +03:00
Ed Zynda db4be4f9a2 feat: implement full alt screen mode with in-memory scrollback
Add ScrollList component for viewport-based message history with lazy
rendering and offset-based scrolling. Implement MessageItem system for
user, assistant, tool, system, and error messages with pre-rendered
styled content from MessageRenderer.

Key changes:
- ScrollList: height-constrained viewport with itemGap support, padding
  to ensure fixed height for sticky bottom layout
- MessageItem implementations with preRendered content from MessageRenderer
- refreshContent() pattern for efficient ScrollList updates
- Mouse wheel scrolling (3 lines per tick) with auto-scroll behavior
- All message types (user, assistant, tool, system, error, extension)
  properly added to in-memory scrollback
- PgUp/PgDn/Alt+Home/Alt+End keybindings for navigation
- Removed tea.Println() calls for alt screen compatibility
- Sticky bottom layout: input, separator, status bar fixed at bottom

Files added:
- internal/ui/scrolllist.go (ScrollList component)
- internal/ui/message_items.go (MessageItem implementations)

Files modified:
- internal/ui/model.go (main integration)
- internal/ui/*.go (alt screen config for components)
2026-03-31 16:12:30 +03:00
Ed Zynda 80093e69ed remove 2026-03-31 15:08:46 +03:00
Ed Zynda ef519ba517 feat(acpserver): implement session/set_model ACP method
Add SetSessionModel method to the ACP agent, allowing clients to change
the active LLM model for a session at runtime. The method looks up the
session in the registry and delegates to kit.SetModel().

Verified with smoke test: session/set_model now returns success instead
of 'Method not found' error.
2026-03-31 15:05:23 +03:00
Ed Zynda d79eb1f0fa refactor(pkg/kit): use fantasy type aliases for LLM types with clean SDK names
Replace concrete LLMMessage/LLMUsage/LLMResponse/LLMFilePart structs with
type aliases to charm.land/fantasy types, exposing them under clean
LLM-prefixed names. This gives SDK consumers full access to rich message
parts (tool calls, reasoning, tool results) without importing fantasy
directly.

Key changes:
- LLM types are now aliases: LLMMessage=fantasy.Message, etc.
- Added aliases for all part types: LLMTextPart, LLMToolCallPart, etc.
- Re-exported constructors: NewLLMUserMessage, NewLLMSystemMessage
- Removed lossy conversion helpers (llm_convert.go, fantasyMsgsToKit)
- Updated all internal packages to use aliases consistently
- Added ACP smoke test script and prompt template
- Fixed lint issues: unused vars, modernize min() usage
2026-03-31 14:26:49 +03:00
Ed Zynda ac8ee6525d refactor(pkg/kit): replace fantasy type aliases with concrete LLM* structs
Remove charm.land/fantasy from the public API surface of pkg/kit by
replacing the four type aliases with concrete Kit-owned structs:

- LLMMessage  {Role LLMMessageRole, Content string}
- LLMUsage    {InputTokens, OutputTokens, TotalTokens, ...}
- LLMResponse {Content, FinishReason, Usage}
- LLMFilePart {Filename, Data []byte, MediaType}

Add LLMMessageRole type with user/assistant/system/tool constants.

Introduce pkg/kit/llm_convert.go as the single boundary layer where
Kit types convert to/from fantasy types internally. All callers in
pkg/kit, pkg/kit/compaction.go, pkg/kit/extensions_bridge.go, and
internal/app/app.go cross through this layer.

ContextPrepareHook.Messages and ContextPrepareResult.Messages change
from []fantasy.Message to []LLMMessage. extensions_bridge.go drops
its fantasy and strings imports entirely.

internal/app/app_test.go switches &fantasy.Usage{} to &kit.LLMUsage{}.

Add seven new tests in types_test.go covering concrete construction,
role constants, JSON snake_case tags, and round-trip conversion.
2026-03-31 13:44:05 +03:00
Ed Zynda e35e8382d6 fix(app): correct drainQueue QueueUpdatedEvent emission
- Remove always-zero queueLen variable: len() was measured after
  clearing the queue, so it was unconditionally 0 and the variable
  was dead code
- Emit QueueUpdatedEvent{Length: 0} explicitly to make intent clear
- Also emit QueueUpdatedEvent when a second batch is pulled mid-loop;
  previously the queue was silently cleared without notifying the UI,
  leaving queuedMessages stuck in the displayed-queued state forever
2026-03-31 13:19:09 +03:00
Ed Zynda fbb3408a25 chore(prompts): add new-prompt template
/new-prompt <description> scaffolds a new .kit/prompts/ template.
Explains the file format, argument substitution syntax, naming
conventions, and writing guidelines.
2026-03-31 13:04:11 +03:00
Ed Zynda 44fed9a647 chore(prompts): add commit-push prompt template
Provides a /commit-push slash command that reviews git status and diff,
stages all changes, writes a conventional commit message, commits, and
pushes to the current branch.
2026-03-31 13:03:14 +03:00
Ed Zynda e7f11487b9 remove CompactRenderer and --compact flag
The compact display mode was purely a UI concern that added complexity
without providing unique value. Anyone wanting compact-style formatting
can implement it as an extension using the Renderer interface.

- Delete internal/ui/compact_renderer.go
- Remove renderToolBodyCompact and all compact tool body renderers from
  tool_renderers.go
- Simplify NewCLI(debug bool) — drop compact parameter
- Simplify NewStreamComponent(width, modelName) — drop compactMode parameter
- Remove CompactMode from AppModelOptions, app.Options, CLISetupOptions
- Remove Compact from internal/config/config.go
- Remove --compact flag, var, and viper binding from cmd/root.go
- Update format.go: remove CompactRenderer interface compile-time check
  and clean up comments
2026-03-31 13:01:30 +03:00
Ed Zynda 054c417603 fix: render reasoning blocks when resuming sessions
When using /resume to resume a session, reasoning/thinking content
was not being displayed even though it was saved in the session file.

Changes:
- Add RenderReasoningBlock to Renderer interface
- Implement RenderReasoningBlock for MessageRenderer with muted italic
  styling matching live streaming output
- Implement RenderReasoningBlock for CompactRenderer with same styling
- Update renderSessionHistory to render reasoning content before
  assistant message text

Fixes: reasoning blocks now populate correctly when resuming sessions
2026-03-31 10:34:10 +03:00
Ed Zynda 94d62a6ef0 Fix ACP thinking tag parsing to handle format
The Qwen model outputs thinking content wrapped in  tags
(not <thinking>). Updated parseThinkingTags to detect and handle
both formats:
- <thinking>...</thinking> (long format)
-   (short format)

Also removed the hasProperReasoningEvents logic that was preventing
thinking tag parsing from working correctly. Now both ReasoningDeltaEvent
(from models with proper reasoning APIs) and thinking tags in text
(from models like Qwen) are handled together, matching the TUI behavior.
2026-03-30 20:38:49 +03:00
Ed Zynda 91e6dfd2c8 Prevent double-sending of thinking content in ACP
Track whether a model sends proper ReasoningDeltaEvent events. If so,
skip parsing <thinking> tags from text to avoid sending reasoning content
twice (once as proper reasoning, once parsed from text).

Also reset the tracking state at the start of each new prompt turn.
2026-03-30 20:33:46 +03:00
Ed Zynda b6a0c4b44c Add thinking tag parsing for ACP
Parse <thinking>...</thinking> tags from models (Qwen, DeepSeek) that
wrap reasoning content in XML-style tags instead of using proper
reasoning events.

When text chunks contain thinking tags:
- Extract content between tags and send as reasoning/thought updates
- Send content outside tags as regular message text
- Track state across chunks to handle streaming properly

This mirrors the TUI's thinking tag parsing behavior.
2026-03-30 20:30:22 +03:00
Ed Zynda 8eb0fa855a Fix ACP file attachment support
- Implement proper handling for all ACP content block types:
  - ContentBlockText: extracts text content
  - ContentBlockImage: decodes base64 to LLMFilePart
  - ContentBlockAudio: decodes base64 to LLMFilePart
  - ContentBlockResource: handles text and binary embedded resources
  - ContentBlockResourceLink: reads files from disk

- Text files are now included inline in the message (not as FilePart)
  to avoid OpenAI API errors. Only binary files (images, audio, PDFs)
  are sent as FilePart attachments.

- Add fallback MIME types when not provided by client
- Add default prompt text when user attaches files without text
- Add comprehensive debug logging for content extraction
- Enable debug logging in ACP command when --debug flag is used
2026-03-30 20:28:14 +03:00
Ed Zynda 3bf696c546 prompts 2026-03-30 18:30:53 +03:00
Ed Zynda 3e461a0539 chore: unignore .kit/prompts directory 2026-03-30 18:30:21 +03:00
48 changed files with 2916 additions and 1568 deletions
+1
View File
@@ -3,6 +3,7 @@
.env
.kit/*
!.kit/extensions/
!.kit/prompts/
aidocs/
*.log
/kit
+37
View File
@@ -0,0 +1,37 @@
---
description: Run ACP smoke test against opencode/kimi-k2.5 to verify JSON-RPC stdio works
---
Run the ACP smoke test to verify the Kit ACP server works correctly over JSON-RPC stdio with streaming responses.
## Steps
1. Build the kit binary:
```bash
go build -o output/kit ./cmd/kit
```
2. Run the smoke test Python script against opencode/kimi-k2.5:
```bash
python3 scripts/acp_smoke_test.py
```
3. Verify the output shows:
- `session/new` returns a valid `sessionId`
- `session/prompt` streams `agent_thought_chunk` notifications (reasoning)
- `session/prompt` streams `agent_message_chunk` notifications (response)
- Final result has `stopReason: "end_turn"`
- `✓ SMOKE TEST PASSED` at the end
4. If the test fails, check:
- `output/kit` binary exists and is executable
- `OPENCODE_API_KEY` or `OPENCODE_ZEN_API_KEY` environment variable is set
- `scripts/acp_smoke_test.py` exists
- The model `opencode/kimi-k2.5` is available (`kit models opencode | grep kimi-k2.5`)
5. For testing with a different model, edit the script or set the `MODEL` variable:
```bash
MODEL=anthropic/claude-sonnet-4-5 python3 scripts/acp_smoke_test.py
```
The smoke test exercises the full ACP protocol: session lifecycle, streaming notifications, and tool-free prompt completion.
+30
View File
@@ -0,0 +1,30 @@
---
description: Stage, commit, and push changes with an auto-generated conventional commit message
---
Review the current git status and diff, then stage all changes, write a concise conventional commit message, commit, and push to the current branch.
## Steps
1. **Check status**: `git status` — understand what has changed
2. **Review the diff**: `git diff` (and `git diff --cached` if anything is already staged) — read the actual changes
3. **Stage everything**: `git add -A`
4. **Craft the commit message** following Conventional Commits:
- Format: `<type>(<scope>): <short summary>`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `perf`, `build`
- Scope: optional, the subsystem affected (e.g. `ui`, `cmd`, `config`)
- Summary: imperative mood, lowercase, no trailing period, ≤72 chars
- Body: add a blank line then bullet points for non-trivial changes
- Do **not** include "Generated by" or similar noise
5. **Commit**: `git commit -m "<message>"`
6. **Push**: `git push`
## Guidelines
- Read the actual diff — do not guess from filenames alone
- Prefer one well-scoped commit; do not split unless the changes are clearly unrelated
- Keep the subject line under 72 characters
- Use the body to explain *what* and *why*, not *how*
- If there is nothing to commit, say so and stop
$@
+47
View File
@@ -0,0 +1,47 @@
---
description: Scaffold a new prompt template in .kit/prompts/
---
Create a new kit prompt template. The user wants a prompt that does: $@
## What a prompt template is
A prompt template is a `.md` file in `.kit/prompts/` (project-local) or `~/.kit/prompts/` (global).
It becomes a `/slug` slash command in the kit input box — typed as `/filename` with optional arguments.
## File format
```
---
description: One-line description shown in autocomplete
---
Body text of the prompt. Use $@ for all user-supplied arguments,
$1 $2 etc. for positional arguments.
```
- **Filename** → slug: `commit-push.md` becomes `/commit-push`
- **Frontmatter**: only `description` is recognised; keep it under ~80 chars
- **Body**: plain markdown; the full text is submitted as the user's message when the template fires
- **Arguments**: `$@` expands to everything the user typed after the slash command name;
`$1`, `$2` for individual positional args; omit entirely if no arguments are needed
## Steps
1. **Understand the workflow** the user described in `$@` — ask a clarifying question if the intent is ambiguous
2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`)
3. **Write the description**: one sentence, imperative, fits in autocomplete
4. **Draft the body**:
- Open with a single sentence stating the goal
- Use `## Steps` for multi-step workflows; use plain prose for simple prompts
- Be specific: name commands, flags, and file paths where relevant
- End with `$@` on its own line if the user might want to pass context or a hint; omit if the prompt is self-contained
5. **Write the file** to `.kit/prompts/<slug>.md`
6. **Confirm** by showing the final file content and the slash command that activates it
## Guidelines
- Keep prompts action-oriented — they should tell kit *what to do*, not just *what to think about*
- Prefer concrete steps over vague instructions
- A prompt that does one thing well beats one that tries to cover every edge case
- If the workflow already exists as a prompt, suggest extending it instead of duplicating
+70
View File
@@ -0,0 +1,70 @@
---
description: Semantic version tagging workflow - analyzes commits and tags releases
---
# Release Tagging Workflow
Tag a new version of this Go project following semantic versioning.
## Steps
1. **Fetch remote tags**: `git fetch --tags origin`
2. **Find latest version**: `git tag -l | sort -V | tail -5` to see recent tags
3. **Analyze changes since last tag**:
- `git log <latest-tag>..HEAD --oneline` - list commits
- `git diff <latest-tag>..HEAD --stat` - see file stats
- `git diff <latest-tag>..HEAD --name-only` - see changed files
4. **Determine version bump** (Semantic Versioning):
- **MAJOR (X.0.0)**: Breaking API changes, incompatible modifications
- **MINOR (0.X.0)**: New features, backward-compatible additions
- **PATCH (0.0.X)**: Bug fixes, backward-compatible fixes
Look for indicators:
- `feat:` or `feature:` commits → MINOR
- `fix:` or `bugfix:` commits → PATCH
- `breaking:` or `BREAKING CHANGE:` → MAJOR
- Breaking API changes in `pkg/` or public interfaces → MAJOR
- New commands, flags, or features → MINOR
- Documentation-only changes → PATCH (or skip)
5. **Calculate new version**: Increment appropriate segment, reset lower segments to 0
6. **Draft tag message**:
- Summarize key changes from commits
- Group by type (Features, Fixes, Breaking Changes)
- Keep concise but informative
7. **Create annotated tag**: `git tag -a vX.Y.Z -m "vX.Y.Z - <summary>\n\n<detailed list>"`
8. **Push tag**: `git push origin vX.Y.Z`
## Guidelines
- Always fetch remote tags first to avoid conflicts
- Use annotated tags (`-a`) with descriptive messages
- Follow semver strictly - when in doubt, prefer conservative bump (patch over minor)
- For Go projects, changes to `pkg/` or exported APIs warrant careful version consideration
- If no changes since last tag, suggest skipping the release
- Include commit summaries in the tag message body
## Example Tag Message Format
```
v0.30.1 - Bug fixes for model handling and UI improvements
Fixes:
- Properly handle think tags from Qwen/DeepSeek models
- Handle custom provider model persistence and bare model names
Improvements:
- UI style refactoring and cleanup
```
Wait for the user to confirm the version and message before executing tag commands.
---
$@
+3
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"syscall"
"github.com/charmbracelet/log"
acp "github.com/coder/acp-go-sdk"
"github.com/mark3labs/kit/internal/acpserver"
@@ -54,6 +55,8 @@ func runACP(cmd *cobra.Command, _ []string) error {
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
// Also set charmbracelet/log level for acpserver package logging
log.SetLevel(log.DebugLevel)
}
// Wait for either the client to disconnect or a signal.
+2 -8
View File
@@ -10,7 +10,6 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/auth"
@@ -38,7 +37,6 @@ var (
noExitFlag bool
maxSteps int
streamFlag bool // Enable streaming output
compactMode bool // Enable compact output mode
autoCompactFlag bool // Enable auto-compaction near context limit
// Session management
@@ -280,8 +278,6 @@ func init() {
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
rootCmd.PersistentFlags().
BoolVar(&streamFlag, "stream", true, "enable streaming output for faster response display")
rootCmd.PersistentFlags().
BoolVar(&compactMode, "compact", false, "enable compact output mode without fancy styling")
rootCmd.PersistentFlags().
BoolVar(&autoCompactFlag, "auto-compact", false, "auto-compact conversation when near context limit")
rootCmd.PersistentFlags().
@@ -325,7 +321,6 @@ func init() {
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
_ = viper.BindPFlag("auto-compact", rootCmd.PersistentFlags().Lookup("auto-compact"))
_ = viper.BindPFlag("provider-url", rootCmd.PersistentFlags().Lookup("provider-url"))
@@ -728,7 +723,7 @@ func runNormalMode(ctx context.Context) error {
var spinnerFunc kit.SpinnerFunc
if !quietFlag {
spinnerFunc = func(fn func() error) error {
tempCli, tempErr := ui.NewCLI(viper.GetBool("debug"), viper.GetBool("compact"))
tempCli, tempErr := ui.NewCLI(viper.GetBool("debug"))
if tempErr == nil {
return tempCli.ShowSpinner(fn)
}
@@ -792,7 +787,7 @@ func runNormalMode(ctx context.Context) error {
// Load existing messages from resumed/continued sessions.
treeSession := kitInstance.GetTreeSession()
var messages []fantasy.Message
var messages []kit.LLMMessage
if treeSession != nil {
messages = treeSession.GetLLMMessages()
}
@@ -1804,7 +1799,6 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
-2
View File
@@ -41,7 +41,6 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
@@ -131,7 +130,6 @@ func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
Agent: agentAdapter,
ModelString: viper.GetString("model"),
Debug: viper.GetBool("debug"),
Compact: viper.GetBool("compact"),
Quiet: quietFlag,
ShowDebug: false,
ProviderAPIKey: viper.GetString("provider-api-key"),
+337 -17
View File
@@ -7,8 +7,11 @@ package acpserver
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/charmbracelet/log"
@@ -20,6 +23,17 @@ import (
// Version is injected at build time; fallback to "dev".
var Version = "dev"
// thinkingTagOpen and thinkingTagClose are the XML-style tags that some models
// (Qwen, DeepSeek) wrap reasoning content in. We parse these to extract
// reasoning/thinking content and send it as ACP thought updates.
// Also support <think> format used by some models.
const (
thinkingTagOpen = "<thinking>"
thinkingTagClose = "</thinking>"
shortThinkTagOpen = "<think>"
shortThinkTagClose = "</think>"
)
// Agent implements the acp.Agent interface, delegating to Kit for LLM
// execution, tool calls, and session management.
type Agent struct {
@@ -28,6 +42,10 @@ type Agent struct {
// toolCallCounter provides unique IDs for tool calls within a turn.
toolCallCounter atomic.Int64
// inThinkingTag tracks whether we're currently inside a <thinking> tag
// when parsing streaming content from models that wrap reasoning in XML tags.
inThinkingTag bool
}
// NewAgent creates a new ACP agent backed by Kit.
@@ -111,13 +129,23 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
)
}
// Extract text from prompt content blocks.
promptText := extractPromptText(params.Prompt)
if promptText == "" {
// Extract text and file attachments from prompt content blocks.
promptText, files := extractPromptContent(params.Prompt)
if promptText == "" && len(files) == 0 {
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
// If we have files but no text prompt, add a default prompt
// This is required because the underlying LLM library needs a non-empty prompt
// when there are no previous messages in the conversation.
if promptText == "" && len(files) > 0 {
promptText = "Please analyze the attached file."
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText), "files", len(files))
// Reset thinking tag state for this new prompt turn
a.inThinkingTag = false
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
@@ -129,7 +157,13 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
defer unsub()
// Run the prompt through Kit's full turn lifecycle.
_, err := sess.kit.PromptResult(promptCtx, promptText)
// Use PromptResultWithFiles when file attachments are present.
var err error
if len(files) > 0 {
_, err = sess.kit.PromptResultWithFiles(promptCtx, promptText, files)
} else {
_, err = sess.kit.PromptResult(promptCtx, promptText)
}
if err != nil {
if promptCtx.Err() != nil {
return acp.PromptResponse{
@@ -162,6 +196,24 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// SetSessionModel changes the active model for a session.
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
}
modelID := string(params.ModelId)
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
if err := sess.kit.SetModel(ctx, modelID); err != nil {
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
}
return acp.SetSessionModelResponse{}, nil
}
// ---------------------------------------------------------------------------
// Event streaming: Kit events → ACP SessionUpdate notifications
// ---------------------------------------------------------------------------
@@ -178,8 +230,24 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
var update *acp.SessionUpdate
switch ev := e.(type) {
case kit.MessageUpdateEvent:
u := acp.UpdateAgentMessageText(ev.Chunk)
update = &u
// Handle models that wrap reasoning in <thinking> tags (Qwen, DeepSeek)
// Parse the chunk and separate reasoning from regular text
reasoning, text := a.parseThinkingTags(ev.Chunk)
// Send reasoning update if we have reasoning content
if reasoning != "" {
u := acp.UpdateAgentThoughtText(reasoning)
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
SessionId: sessionID,
Update: u,
})
}
// Send text update if we have text content
if text != "" {
u := acp.UpdateAgentMessageText(text)
update = &u
}
case kit.ReasoningDeltaEvent:
u := acp.UpdateAgentThoughtText(ev.Delta)
@@ -231,19 +299,271 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
// Helpers
// ---------------------------------------------------------------------------
// extractPromptText extracts the concatenated text content from ACP content
// blocks. Non-text blocks are ignored for now.
func extractPromptText(blocks []acp.ContentBlock) string {
var text string
for _, block := range blocks {
if block.Text != nil {
if text != "" {
text += "\n"
// extractPromptContent extracts text and file attachments from ACP content blocks.
// It converts supported content blocks (image, audio, resource) to Kit's LLMFilePart.
func extractPromptContent(blocks []acp.ContentBlock) (string, []kit.LLMFilePart) {
var textParts []string
var files []kit.LLMFilePart
log.Debug("acp: extracting content", "blocks", len(blocks))
for i, block := range blocks {
switch {
// Text content
case block.Text != nil:
log.Debug("acp: content block", "index", i, "type", "text", "len", len(block.Text.Text))
textParts = append(textParts, block.Text.Text)
// Image data (base64)
case block.Image != nil:
mimeType := block.Image.MimeType
if mimeType == "" {
mimeType = "image/png" // Default fallback
}
text += block.Text.Text
log.Debug("acp: content block", "index", i, "type", "image", "mime", mimeType, "data_len", len(block.Image.Data))
if data, err := base64.StdEncoding.DecodeString(block.Image.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "image.png",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode image", "error", err)
}
// Audio data (base64)
case block.Audio != nil:
mimeType := block.Audio.MimeType
if mimeType == "" {
mimeType = "audio/wav" // Default fallback
}
log.Debug("acp: content block", "index", i, "type", "audio", "mime", mimeType)
if data, err := base64.StdEncoding.DecodeString(block.Audio.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "audio.wav",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode audio", "error", err)
}
// Embedded resource (text or binary file content)
case block.Resource != nil:
log.Debug("acp: content block", "index", i, "type", "resource")
res := block.Resource.Resource
// Text resource - append as text content with file reference
if res.TextResourceContents != nil {
uri := res.TextResourceContents.Uri
content := res.TextResourceContents.Text
mimeType := "text/plain"
if res.TextResourceContents.MimeType != nil {
mimeType = *res.TextResourceContents.MimeType
}
log.Debug("acp: text resource", "uri", uri, "mime", mimeType, "len", len(content))
// Text files are included as formatted text, NOT as FilePart
// FilePart is for binary files (images, audio, PDFs) only
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, content))
}
// Binary resource (base64 blob) - these become FilePart
if res.BlobResourceContents != nil {
uri := res.BlobResourceContents.Uri
mimeType := "application/octet-stream"
if res.BlobResourceContents.MimeType != nil {
mimeType = *res.BlobResourceContents.MimeType
}
log.Debug("acp: binary resource", "uri", uri, "mime", mimeType, "blob_len", len(res.BlobResourceContents.Blob))
if data, err := base64.StdEncoding.DecodeString(res.BlobResourceContents.Blob); err == nil {
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode binary resource", "error", err)
}
}
// Resource link (file reference without embedded content)
case block.ResourceLink != nil:
uri := block.ResourceLink.Uri
name := block.ResourceLink.Name
log.Debug("acp: content block", "index", i, "type", "resource_link", "uri", uri, "name", name)
// For resource links, we'll try to read the file from disk
// This requires the file URI to be accessible (file:// scheme)
if content, err := readResourceFromURI(uri); err == nil {
// Detect if it's a text file or binary file
mimeType := "text/plain"
if block.ResourceLink.MimeType != nil {
mimeType = *block.ResourceLink.MimeType
}
log.Debug("acp: resource link loaded", "uri", uri, "mime", mimeType, "size", len(content))
// Only create FilePart for binary files (images, audio, PDFs, etc.)
// Text files are included as formatted text in the message
if isTextMimeType(mimeType) || looksLikeText(content) {
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, string(content)))
} else {
// Binary file - create FilePart for models that support it
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: content,
MediaType: mimeType,
})
}
} else {
// If we can't read it, include as a text reference
log.Debug("acp: resource link failed to load", "uri", uri, "error", err)
textParts = append(textParts, fmt.Sprintf("[Referenced file: %s]", uri))
}
default:
log.Debug("acp: content block", "index", i, "type", "unknown/unhandled")
}
}
return text
// Debug log the extracted content
for i, f := range files {
log.Debug("acp: extracted file", "index", i, "filename", f.Filename, "mime", f.MediaType, "size", len(f.Data))
}
return strings.Join(textParts, "\n"), files
}
// parseThinkingTags parses a text chunk for <thinking> or tags and separates
// reasoning content from regular text. This handles models (Qwen, DeepSeek)
// that wrap reasoning in XML-style tags instead of using proper reasoning events.
// Returns (reasoningContent, textContent).
func (a *Agent) parseThinkingTags(chunk string) (reasoning string, text string) {
// Handle empty chunk
if chunk == "" {
return "", ""
}
// Determine which tag format to use (long or short)
openTag := thinkingTagOpen
closeTag := thinkingTagClose
if strings.Contains(chunk, shortThinkTagOpen) || strings.Contains(chunk, shortThinkTagClose) {
openTag = shortThinkTagOpen
closeTag = shortThinkTagClose
} else if !strings.Contains(chunk, thinkingTagOpen) && !strings.Contains(chunk, thinkingTagClose) && !a.inThinkingTag {
// No tags at all and not in thinking mode - return as text
return "", chunk
}
// Check for opening tag
if strings.Contains(chunk, openTag) {
parts := strings.SplitN(chunk, openTag, 2)
// Content before the opening tag is regular text
if !a.inThinkingTag && parts[0] != "" {
text = parts[0]
}
a.inThinkingTag = true
// Content after the opening tag is reasoning
if len(parts) > 1 {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], closeTag) {
innerParts := strings.SplitN(parts[1], closeTag, 2)
reasoning = innerParts[0]
a.inThinkingTag = false
// Content after closing tag is regular text
if len(innerParts) > 1 && innerParts[1] != "" {
text += innerParts[1]
}
} else if parts[1] != "" {
// No closing tag yet, all remaining content is reasoning
reasoning = parts[1]
}
}
return reasoning, text
}
// Check for closing tag
if strings.Contains(chunk, closeTag) {
parts := strings.SplitN(chunk, closeTag, 2)
a.inThinkingTag = false
// Content before closing tag is reasoning
reasoning = parts[0]
// Content after closing tag is regular text
if len(parts) > 1 && parts[1] != "" {
text = parts[1]
}
return reasoning, text
}
// No tags found - content goes to current mode
if a.inThinkingTag {
return chunk, ""
}
return "", chunk
}
// isTextMimeType returns true if the MIME type indicates text content.
func isTextMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "text/") ||
mimeType == "application/json" ||
mimeType == "application/xml" ||
mimeType == "application/javascript" ||
mimeType == "application/typescript" ||
mimeType == "application/x-sh" ||
mimeType == "application/x-python" ||
mimeType == "application/x-yaml" ||
mimeType == "application/x-toml"
}
// looksLikeText checks if the content appears to be text (not binary).
// It samples the first 512 bytes and checks for null bytes or high
// concentration of non-printable characters.
func looksLikeText(data []byte) bool {
if len(data) == 0 {
return true
}
// Check first 512 bytes (or less if file is smaller)
sampleSize := min(len(data), 512)
sample := data[:sampleSize]
// Count non-printable characters
nonPrintable := 0
for _, b := range sample {
// Null byte indicates binary
if b == 0 {
return false
}
// Count control characters (except common whitespace)
if b < 32 && b != '\n' && b != '\r' && b != '\t' {
nonPrintable++
}
}
// If more than 30% non-printable, consider it binary
return float64(nonPrintable)/float64(sampleSize) < 0.3
}
// extractFilenameFromURI extracts a filename from a file URI or path.
func extractFilenameFromURI(uri string) string {
// Handle file:// URIs
uri = strings.TrimPrefix(uri, "file://")
// Extract basename
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
return uri[idx+1:]
}
return uri
}
// readResourceFromURI attempts to read file content from a file:// URI.
func readResourceFromURI(uri string) ([]byte, error) {
if !strings.HasPrefix(uri, "file://") {
return nil, fmt.Errorf("unsupported URI scheme: %s", uri)
}
path := uri[7:] // Remove file:// prefix
return os.ReadFile(path)
}
// parseToolArgs attempts to parse a JSON tool args string into a map for
+24 -12
View File
@@ -20,7 +20,7 @@ import (
// queueItem holds a prompt and optional image attachments for the execution queue.
type queueItem struct {
Prompt string
Files []fantasy.FilePart
Files []kit.LLMFilePart
}
// App is the application-layer orchestrator. It owns the agentic loop,
@@ -82,7 +82,7 @@ type App struct {
// New creates a new App with the provided options and pre-loaded messages.
// initialMessages may be nil or empty for a fresh session.
func New(opts Options, initialMessages []fantasy.Message) *App {
func New(opts Options, initialMessages []kit.LLMMessage) *App {
rootCtx, rootCancel := context.WithCancel(context.Background())
return &App{
opts: opts,
@@ -126,9 +126,8 @@ func (a *App) Run(prompt string) int {
// If the app is idle the prompt executes immediately; otherwise it is queued.
// Returns the current queue depth (0 = started immediately, >0 = queued).
//
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
// to fantasy.FilePart).
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
// Satisfies ui.AppController.
func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
@@ -314,12 +313,12 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
//
// Satisfies ui.AppController.
func (a *App) AddContextMessage(text string) {
msg := fantasy.NewUserMessage(text)
a.store.Add(msg)
kitMsg := fantasy.NewUserMessage(text)
a.store.Add(kitMsg)
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendLLMMessage(msg)
_, _ = ts.AppendLLMMessage(fantasy.NewUserMessage(text))
}
}
@@ -357,6 +356,15 @@ func (a *App) CompactConversation(customInstructions string) error {
a.mu.Unlock()
}()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
a.sendEvent(CompactErrorEvent{Err: err})
@@ -506,11 +514,10 @@ func (a *App) drainQueue(first queueItem) {
a.mu.Lock()
items = append(items, a.queue...)
a.queue = a.queue[:0] // Clear the queue
queueLen := len(a.queue)
a.mu.Unlock()
// Send queue updated event (queue is now empty)
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
// Notify UI: all queued messages have been consumed into this batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
// Process all collected items as a single batch
a.runQueueBatch(items)
@@ -543,6 +550,11 @@ func (a *App) drainQueue(first queueItem) {
}
a.mu.Unlock()
if hasMore {
// Notify UI: these newly queued messages have been consumed into the next batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
}
if !hasMore {
// No more items, we're done
break
@@ -609,7 +621,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// executeStep runs a single agentic step by delegating to the SDK's
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
// session persistence, hooks, extension events, and the generation loop.
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely.
if a.opts.PromptFunc != nil {
return a.opts.PromptFunc(ctx, prompt)
+6 -8
View File
@@ -7,8 +7,6 @@ import (
"testing"
"time"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -574,13 +572,13 @@ func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
TotalUsage: &kit.LLMUsage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &fantasy.Usage{InputTokens: 456},
FinalUsage: &kit.LLMUsage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
@@ -608,13 +606,13 @@ func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) {
// Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
TotalUsage: &kit.LLMUsage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &fantasy.Usage{InputTokens: 0, OutputTokens: 150},
FinalUsage: &kit.LLMUsage{InputTokens: 0, OutputTokens: 150},
}, "prompt", false)
usage.mu.Lock()
@@ -642,11 +640,11 @@ func TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly(t *testing.T) {
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
TotalUsage: &kit.LLMUsage{
InputTokens: 1000,
OutputTokens: 200,
},
FinalUsage: &fantasy.Usage{
FinalUsage: &kit.LLMUsage{
InputTokens: 1000, // Full context including history
OutputTokens: 200,
},
+3 -3
View File
@@ -1,6 +1,6 @@
package app
import "charm.land/fantasy"
import kit "github.com/mark3labs/kit/pkg/kit"
// StreamChunkEvent is sent by the app layer when a streaming text delta arrives
// from the LLM. Each chunk contains an incremental portion of the response.
@@ -118,8 +118,8 @@ type SpinnerEvent struct {
// MessageCreatedEvent is sent when a new message is added to the message store.
// This allows the TUI to stay in sync with the conversation history.
type MessageCreatedEvent struct {
// Message is the fantasy message that was added to the store.
Message fantasy.Message
// Message is the message that was added to the store.
Message kit.LLMMessage
}
// CompactCompleteEvent is sent when a /compact operation finishes successfully.
+9 -9
View File
@@ -3,14 +3,14 @@ package app
import (
"sync"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// MessageStore is a thread-safe in-memory store for the conversation history.
// On-disk persistence is handled by the TreeManager at the app/SDK layer.
type MessageStore struct {
mu sync.RWMutex
messages []fantasy.Message
messages []kit.LLMMessage
}
// NewMessageStore creates an empty MessageStore.
@@ -20,14 +20,14 @@ func NewMessageStore() *MessageStore {
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
// given messages. This is used when loading an existing session at startup.
func NewMessageStoreWithMessages(msgs []fantasy.Message) *MessageStore {
cp := make([]fantasy.Message, len(msgs))
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
return &MessageStore{messages: cp}
}
// Add appends a single message to the store.
func (s *MessageStore) Add(msg fantasy.Message) {
func (s *MessageStore) Add(msg kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
@@ -36,22 +36,22 @@ func (s *MessageStore) Add(msg fantasy.Message) {
// Replace replaces the entire message history with the given slice. This is
// used after an agent step returns the full updated conversation (including
// tool calls and results).
func (s *MessageStore) Replace(msgs []fantasy.Message) {
func (s *MessageStore) Replace(msgs []kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
cp := make([]fantasy.Message, len(msgs))
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
s.messages = cp
}
// GetAll returns a snapshot copy of the current message slice.
// The returned slice is safe to modify without affecting the store.
func (s *MessageStore) GetAll() []fantasy.Message {
func (s *MessageStore) GetAll() []kit.LLMMessage {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make([]fantasy.Message, len(s.messages))
cp := make([]kit.LLMMessage, len(s.messages))
copy(cp, s.messages)
return cp
}
+33 -27
View File
@@ -4,16 +4,29 @@ import (
"testing"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// makeTextMsg builds a minimal fantasy.Message with a single TextPart.
func makeTextMsg(role, text string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRole(role),
// makeTextMsg builds a minimal kit.LLMMessage using fantasy.NewUserMessage
// or constructing with the given role.
func makeTextMsg(role, text string) kit.LLMMessage {
return kit.LLMMessage{
Role: kit.LLMMessageRole(role),
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}
}
// textOf extracts the plain text from an LLMMessage for assertions.
func textOf(msg kit.LLMMessage) string {
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
return tp.Text
}
}
return ""
}
// --------------------------------------------------------------------------
// NewMessageStore / NewMessageStoreWithMessages
// --------------------------------------------------------------------------
@@ -29,7 +42,7 @@ func TestNewMessageStore_empty(t *testing.T) {
}
func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
msgs := []fantasy.Message{
msgs := []kit.LLMMessage{
makeTextMsg("user", "hello"),
makeTextMsg("assistant", "hi"),
}
@@ -42,7 +55,7 @@ func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
// NewMessageStoreWithMessages must deep-copy the slice so that external
// modifications don't affect the store.
func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
msgs := []fantasy.Message{makeTextMsg("user", "hello")}
msgs := []kit.LLMMessage{makeTextMsg("user", "hello")}
s := NewMessageStoreWithMessages(msgs)
// Mutate the source slice.
@@ -52,9 +65,8 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 message, got %d", len(got))
}
tp, ok := got[0].Content[0].(fantasy.TextPart)
if !ok || tp.Text != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", tp.Text)
if textOf(got[0]) != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", textOf(got[0]))
}
}
@@ -80,9 +92,8 @@ func TestAdd_preservesOrder(t *testing.T) {
}
got := s.GetAll()
for i, expected := range texts {
tp, ok := got[i].Content[0].(fantasy.TextPart)
if !ok || tp.Text != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, tp.Text)
if textOf(got[i]) != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, textOf(got[i]))
}
}
}
@@ -95,7 +106,7 @@ func TestReplace_swapsHistory(t *testing.T) {
s := NewMessageStore()
s.Add(makeTextMsg("user", "old"))
replacement := []fantasy.Message{
replacement := []kit.LLMMessage{
makeTextMsg("user", "new1"),
makeTextMsg("assistant", "new2"),
}
@@ -105,25 +116,22 @@ func TestReplace_swapsHistory(t *testing.T) {
t.Fatalf("expected 2 messages after replace, got %d", s.Len())
}
got := s.GetAll()
tp0, _ := got[0].Content[0].(fantasy.TextPart)
tp1, _ := got[1].Content[0].(fantasy.TextPart)
if tp0.Text != "new1" || tp1.Text != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", tp0.Text, tp1.Text)
if textOf(got[0]) != "new1" || textOf(got[1]) != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", textOf(got[0]), textOf(got[1]))
}
}
// Replace must deep-copy the incoming slice.
func TestReplace_isolatesInput(t *testing.T) {
s := NewMessageStore()
replacement := []fantasy.Message{makeTextMsg("user", "original")}
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
s.Replace(replacement)
replacement[0] = makeTextMsg("user", "mutated")
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", tp.Text)
if textOf(got[0]) != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", textOf(got[0]))
}
}
@@ -140,9 +148,8 @@ func TestGetAll_returnsCopy(t *testing.T) {
got[0] = makeTextMsg("user", "mutated")
internal := s.GetAll()
tp, _ := internal[0].Content[0].(fantasy.TextPart)
if tp.Text != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", tp.Text)
if textOf(internal[0]) != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", textOf(internal[0]))
}
}
@@ -179,9 +186,8 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
t.Fatalf("expected 1 message after Clear+Add, got %d", s.Len())
}
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "after" {
t.Fatalf("expected %q, got %q", "after", tp.Text)
if textOf(got[0]) != "after" {
t.Fatalf("expected %q, got %q", "after", textOf(got[0]))
}
}
-4
View File
@@ -67,10 +67,6 @@ type Options struct {
// Debug enables verbose debug logging.
Debug bool
// CompactMode selects the compact renderer instead of the block renderer for
// message formatting.
CompactMode bool
// UsageTracker is an optional callback for recording token usage after each
// agent step. When non-nil, the app layer calls UpdateUsage (or
// EstimateAndUpdateUsage as a fallback) using the usage data returned by the
+49 -12
View File
@@ -428,6 +428,10 @@ type PreviousCompaction struct {
ModifiedFiles []string
}
// StreamCallback is called for each chunk of text during streaming compaction.
// Return a non-nil error to cancel the stream.
type StreamCallback func(delta string) error
// Compact summarises older messages using the LLM, returning the compaction
// result and a new message slice (summary message + preserved recent
// messages).
@@ -442,6 +446,8 @@ type PreviousCompaction struct {
//
// prev carries file tracking from a previous compaction for cumulative
// tracking. Pass nil if there is no prior compaction.
// onChunk is an optional callback for streaming summary text. Pass nil for
// non-streaming compaction.
func Compact(
ctx context.Context,
model fantasy.LanguageModel,
@@ -449,6 +455,7 @@ func Compact(
opts CompactionOptions,
customInstructions string,
prev *PreviousCompaction,
onChunk StreamCallback,
) (*CompactionResult, []fantasy.Message, error) {
opts.defaults()
@@ -487,9 +494,9 @@ func Compact(
var err error
if IsSplitTurn(messages, cutPoint) {
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions)
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, onChunk)
} else {
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions)
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, onChunk)
}
if err != nil {
return nil, nil, err
@@ -527,15 +534,17 @@ func Compact(
}
// compactNormal generates a summary for a clean turn-boundary cut.
// If onChunk is provided, text deltas are streamed to it.
func compactNormal(
ctx context.Context,
model fantasy.LanguageModel,
oldMessages []fantasy.Message,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
conversationText := serializeMessages(oldMessages)
return generateSummary(ctx, model, conversationText, opts, customInstructions)
return generateSummary(ctx, model, conversationText, opts, customInstructions, onChunk)
}
// compactSplitTurn handles the case where the cut point lands mid-turn.
@@ -546,6 +555,7 @@ func compactNormal(
//
// The merged result preserves context from both the older history and the
// beginning of the current long turn.
// If onChunk is provided, both summaries and the separator are streamed.
func compactSplitTurn(
ctx context.Context,
model fantasy.LanguageModel,
@@ -554,6 +564,7 @@ func compactSplitTurn(
cutPoint int,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
// Find where the split turn starts.
turnStart := findTurnStart(allMessages, cutPoint)
@@ -573,12 +584,19 @@ func compactSplitTurn(
// Generate history summary if there are complete turns before the split.
if len(historyMessages) >= 2 {
historySummary, err = generateSummary(ctx, model,
serializeMessages(historyMessages), opts, "")
serializeMessages(historyMessages), opts, "", onChunk)
if err != nil {
return "", fmt.Errorf("split turn history summary failed: %w", err)
}
}
// Stream the separator between history and turn prefix summaries.
if onChunk != nil && historySummary != "" {
if err := onChunk("\n\n---\n\n## Current Turn (in progress)\n\n"); err != nil {
return "", fmt.Errorf("streaming separator failed: %w", err)
}
}
// Generate turn prefix summary.
turnPrefixText := serializeMessages(turnPrefixMessages)
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
@@ -588,16 +606,10 @@ func compactSplitTurn(
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
}
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: turnPrefixText + "\n\n" + turnPrefixPrompt,
})
turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, onChunk)
if err != nil {
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
}
turnPrefixSummary := result.Response.Content.Text()
// Merge the two summaries.
if historySummary != "" && turnPrefixSummary != "" {
@@ -610,12 +622,14 @@ func compactSplitTurn(
}
// generateSummary calls the LLM to produce a structured summary.
// If onChunk is provided, the summary is streamed using Agent.Stream().
func generateSummary(
ctx context.Context,
model fantasy.LanguageModel,
conversationText string,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
userPrompt := opts.SummaryPrompt
if userPrompt == "" {
@@ -628,8 +642,31 @@ func generateSummary(
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
prompt := conversationText + "\n\n" + userPrompt
// Use streaming if onChunk is provided.
if onChunk != nil {
var fullText strings.Builder
_, err := summaryAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
OnTextDelta: func(_, delta string) error {
if delta != "" {
fullText.WriteString(delta)
return onChunk(delta)
}
return nil
},
})
if err != nil {
return "", fmt.Errorf("compaction summarisation (streaming) failed: %w", err)
}
return fullText.String(), nil
}
// Non-streaming path.
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: conversationText + "\n\n" + userPrompt,
Prompt: prompt,
})
if err != nil {
return "", fmt.Errorf("compaction summarisation failed: %w", err)
+2 -2
View File
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleUser, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
-1
View File
@@ -191,7 +191,6 @@ type Config struct {
Model string `json:"model,omitempty" yaml:"model,omitempty"`
MaxSteps int `json:"max-steps,omitempty" yaml:"max-steps,omitempty"`
Debug bool `json:"debug,omitempty" yaml:"debug,omitempty"`
Compact bool `json:"compact,omitempty" yaml:"compact,omitempty"`
SystemPrompt string `json:"system-prompt,omitempty" yaml:"system-prompt,omitempty"`
ProviderAPIKey string `json:"provider-api-key,omitempty" yaml:"provider-api-key,omitempty"`
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
+2 -3
View File
@@ -275,10 +275,9 @@ func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
// Helpers
// --------------------------------------------------------------------------
// newTestStream creates a StreamComponent with a fixed width and model name,
// in non-compact mode.
// newTestStream creates a StreamComponent with a fixed width and model name.
func newTestStream() *StreamComponent {
return NewStreamComponent(false, 80, "test-model")
return NewStreamComponent(80, "test-model")
}
// sendStreamMsg calls component.Update and returns the updated component.
+8 -15
View File
@@ -11,33 +11,26 @@ import (
)
// CLI manages the command-line interface for KIT, providing message rendering,
// user input handling, and display management. It supports both standard and compact
// display modes, handles streaming responses, tracks token usage, and manages the
// overall conversation flow between the user and AI assistants.
// user input handling, and display management. It handles streaming responses,
// tracks token usage, and manages the overall conversation flow between the
// user and AI assistants.
type CLI struct {
renderer Renderer
usageTracker *UsageTracker
width int
compactMode bool
debug bool
modelName string
}
// NewCLI creates and initializes a new CLI instance with the specified display modes.
// The debug parameter enables debug message rendering, while compact enables a more
// condensed display format. Returns an initialized CLI ready for interaction or an
// NewCLI creates and initializes a new CLI instance. The debug parameter enables
// debug message rendering. Returns an initialized CLI ready for interaction or an
// error if initialization fails.
func NewCLI(debug bool, compact bool) (*CLI, error) {
func NewCLI(debug bool) (*CLI, error) {
cli := &CLI{
compactMode: compact,
debug: debug,
debug: debug,
}
cli.updateSize()
if compact {
cli.renderer = NewCompactRenderer(cli.width, debug)
} else {
cli.renderer = newMessageRenderer(cli.width, debug)
}
cli.renderer = newMessageRenderer(cli.width, debug)
return cli, nil
}
+96
View File
@@ -0,0 +1,96 @@
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)
}
-451
View File
@@ -1,451 +0,0 @@
package ui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
)
// CompactRenderer handles rendering messages in a space-efficient compact format,
// optimized for terminals with limited vertical space. It displays messages with
// minimal decorations while maintaining readability and essential information.
type CompactRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
getToolRenderer func(toolName string) *ToolRendererData
}
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping,
// while debug enables additional diagnostic output in rendered messages.
func NewCompactRenderer(width int, debug bool) *CompactRenderer {
return &CompactRenderer{
width: width,
debug: debug,
}
}
// SetWidth updates the terminal width for the renderer, affecting how content
// is wrapped and formatted in subsequent render operations.
func (r *CompactRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message in compact format with a
// distinctive symbol (>) and label. The content is formatted to preserve structure
// while minimizing vertical space usage. Returns a UIMessage with formatted content
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var compactContent string
if strings.Contains(content, "`") {
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
compactContent = r.formatUserAssistantContent(mdContent)
compactContent = removeBlankLines(compactContent)
} else {
compactContent = content
}
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
var formattedLines []string
for i, line := range lines {
if i == 0 {
// First line includes symbol and label
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
} else {
// Subsequent lines without indentation for compact mode
formattedLines = append(formattedLines, line)
}
}
return UIMessage{
Type: UserMessage,
Content: strings.Join(formattedLines, "\n"),
Height: len(formattedLines),
Timestamp: timestamp,
}
}
// RenderAssistantMessage renders an AI assistant's response in compact format with
// a distinctive symbol (<) and the model name as label. Empty content is ignored
// and returns an empty message. Returns a UIMessage with formatted content and metadata.
func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Ignore empty responses - don't render anything
compactContent := r.formatUserAssistantContent(content)
if compactContent == "" {
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
// Use the full model name, fallback to "Assistant" if empty
if modelName == "" {
modelName = "Assistant"
}
label := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render(modelName)
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
var formattedLines []string
for i, line := range lines {
if i == 0 {
// First line includes symbol and label
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
} else {
// Subsequent lines without indentation for compact mode
formattedLines = append(formattedLines, line)
}
}
return UIMessage{
Type: AssistantMessage,
Content: strings.Join(formattedLines, "\n"),
Height: len(formattedLines),
Timestamp: timestamp,
}
}
// RenderToolMessage renders a unified tool block in compact format, combining
// the tool invocation header (icon + display name + params) with the execution
// result body. Status is indicated by icon: checkmark for success, cross for error.
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := GetTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// Status icon
var icon string
iconColor := theme.Success
if isError {
icon = "×"
iconColor = theme.Error
} else {
icon = "✓"
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
var params string
if extRd != nil && extRd.RenderHeader != nil {
params = extRd.RenderHeader(toolArgs, paramBudget)
}
if params == "" {
params = formatToolParams(toolArgs, paramBudget)
}
// Build header line
header := iconStr + " " + nameStr
if params != "" {
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Format body: check extension renderer first, then compact builtin, then default.
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-4)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-4), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
// Use compact summary renderers instead of full tool body renderers.
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
if body == "" {
formatted := r.formatToolResult(toolResult)
if formatted == "" {
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
} else {
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
}
}
}
}
// Combine header + indented body
var lines []string
lines = append(lines, header)
if body != "" {
for line := range strings.SplitSeq(body, "\n") {
lines = append(lines, " "+line)
}
}
return UIMessage{
Type: ToolMessage,
Content: strings.Join(lines, "\n"),
Height: len(lines),
}
}
// RenderSystemMessage renders a system notification or informational message in
// compact format with a distinctive symbol (*) and "System" label. Content is
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
compactContent := r.formatCompactContent(content)
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderErrorMessage renders an error notification in compact format with a
// distinctive error symbol (!) and styling to ensure visibility. The error
// content is displayed in a single line with appropriate color highlighting.
func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Error).Render("!")
label := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).Render("Error")
compactContent := lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatCompactContent(errorMsg))
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
return UIMessage{
Type: ErrorMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderDebugMessage renders diagnostic information in compact format when debug
// mode is enabled. Messages are truncated if they exceed the available width to
// maintain single-line display.
func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
// Truncate message if too long
content := message
if len(content) > r.width-20 {
content = content[:r.width-23] + "..."
}
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderDebugConfigMessage renders configuration settings in compact format for
// debugging purposes. Config entries are displayed as key=value pairs separated
// by commas, truncated if necessary to fit on a single line.
func (r *CompactRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
// Format config as compact key=value pairs
var configPairs []string
for key, value := range config {
if value != nil {
configPairs = append(configPairs, fmt.Sprintf("%s=%v", key, value))
}
}
content := strings.Join(configPairs, ", ")
if len(content) > r.width-20 {
content = content[:r.width-23] + "..."
}
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// formatCompactContent formats content for compact single-line display
func (r *CompactRenderer) formatCompactContent(content string) string {
if content == "" {
return ""
}
// Remove markdown formatting for compact display
content = strings.ReplaceAll(content, "\n", " ")
content = strings.ReplaceAll(content, "\t", " ")
// Collapse multiple spaces
for strings.Contains(content, " ") {
content = strings.ReplaceAll(content, " ", " ")
}
content = strings.TrimSpace(content)
// Truncate if too long (unless in debug mode)
maxLen := max(
// Reserve space for symbol and label more conservatively
r.width-28,
// Minimum width for readability
40)
if !r.debug && len(content) > maxLen {
content = content[:maxLen-3] + "..."
}
return content
}
// formatUserAssistantContent formats user and assistant content using glamour markdown rendering
func (r *CompactRenderer) formatUserAssistantContent(content string) string {
if content == "" {
return ""
}
// Calculate available width more conservatively
// Account for: symbol (1) + spaces (2) + label (up to 20 chars) + space (1) + margin (4)
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// Use glamour to render markdown content with proper width
rendered := toMarkdown(content, availableWidth)
return strings.TrimSuffix(rendered, "\n")
}
// wrapText wraps text to the specified width, preserving existing line breaks
func (r *CompactRenderer) wrapText(text string, width int) string {
if width <= 0 {
return text
}
lines := strings.Split(text, "\n")
var wrappedLines []string
for _, line := range lines {
if len(line) <= width {
wrappedLines = append(wrappedLines, line)
continue
}
// Wrap long lines
words := strings.Fields(line)
if len(words) == 0 {
wrappedLines = append(wrappedLines, line)
continue
}
currentLine := ""
for _, word := range words {
// If adding this word would exceed the width, start a new line
if len(currentLine)+len(word)+1 > width && currentLine != "" {
wrappedLines = append(wrappedLines, currentLine)
currentLine = word
} else {
if currentLine == "" {
currentLine = word
} else {
currentLine += " " + word
}
}
}
if currentLine != "" {
wrappedLines = append(wrappedLines, currentLine)
}
}
return strings.Join(wrappedLines, "\n")
}
// formatToolResult formats tool results preserving formatting but limiting to 5 lines
func (r *CompactRenderer) formatToolResult(result string) string {
if result == "" {
return ""
}
// Check if this is bash output with stdout/stderr tags
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
result = r.formatBashOutput(result)
}
// Calculate available width more conservatively
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// First wrap the text to prevent long lines (tool results are usually plain text, not markdown)
wrappedResult := r.wrapText(result, availableWidth)
// Then limit to 5 lines
lines := strings.Split(wrappedResult, "\n")
if len(lines) > 5 {
lines = lines[:5]
// Add truncation indicator
if len(lines) == 5 && lines[4] != "" {
lines[4] = lines[4] + "..."
} else {
lines = append(lines, "...")
}
}
return strings.Join(lines, "\n")
}
// formatBashOutput formats bash command output by removing stdout/stderr tags
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
// helper.
func (r *CompactRenderer) formatBashOutput(result string) string {
return parseBashOutput(result, GetTheme())
}
// UpdateTheme is a no-op for CompactRenderer since it fetches theme colors
// directly from GetTheme() in each rendering method. This stub satisfies
// the Renderer interface.
func (r *CompactRenderer) UpdateTheme() {
// No-op: theme colors are fetched fresh on each render
}
+1 -2
View File
@@ -25,7 +25,6 @@ type CLISetupOptions struct {
Agent AgentInterface
ModelString string
Debug bool
Compact bool
Quiet bool
ShowDebug bool // Whether to show debug config
ProviderAPIKey string // For OAuth detection
@@ -76,7 +75,7 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
return nil, nil // No CLI in quiet mode
}
cli, err := NewCLI(opts.Debug, opts.Compact)
cli, err := NewCLI(opts.Debug)
if err != nil {
return nil, fmt.Errorf("failed to create CLI: %v", err)
}
+5 -6
View File
@@ -7,12 +7,12 @@ import (
"charm.land/lipgloss/v2"
)
// Renderer is the interface satisfied by both MessageRenderer and
// CompactRenderer. It allows model.go and cli.go to call rendering methods
// without branching on compact mode.
// Renderer is the interface satisfied by MessageRenderer. It allows model.go
// and cli.go to call rendering methods uniformly.
type Renderer interface {
RenderUserMessage(content string, timestamp time.Time) UIMessage
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
RenderReasoningBlock(content string, timestamp time.Time) UIMessage
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
RenderSystemMessage(content string, timestamp time.Time) UIMessage
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
@@ -22,15 +22,14 @@ type Renderer interface {
UpdateTheme()
}
// Compile-time checks that both renderers satisfy the Renderer interface.
// Compile-time check that MessageRenderer satisfies the Renderer interface.
var _ Renderer = (*MessageRenderer)(nil)
var _ Renderer = (*CompactRenderer)(nil)
// parseBashOutput parses <stdout>/<stderr> tagged output from bash tool
// results, styling stderr with the theme's error color. Returns the
// combined, styled output string with tags stripped.
//
// Shared by both MessageRenderer and CompactRenderer.
// Shared by MessageRenderer.
func parseBashOutput(result string, theme Theme) string {
var formattedResult strings.Builder
remaining := result
+33 -6
View File
@@ -486,10 +486,8 @@ func (s *InputComponent) View() tea.View {
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
}
// Popup is now rendered as a centered overlay in AppModel.View()
// instead of inline here to prevent bottom overflow
// Show image attachment indicator when images are pending.
if len(s.pendingImages) > 0 {
@@ -533,11 +531,40 @@ func (s *InputComponent) View() tea.View {
view.WriteString(helpStyle.Render(hint))
}
return tea.NewView(containerStyle.Render(view.String()))
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
}
// renderPopup renders the autocomplete popup for slash command suggestions.
func (s *InputComponent) renderPopup() string {
// When rendered inline (not centered), returns the styled popup content.
// RenderPopupCentered renders the popup as a centered overlay.
func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string {
if !s.showPopup || len(s.filtered) == 0 {
return ""
}
popupContent := s.renderPopupWithOptions(true)
// Center popup using lipgloss.Place
positioned := lipgloss.Place(
termWidth,
termHeight,
lipgloss.Center,
lipgloss.Center,
popupContent,
)
return positioned
}
// renderPopupWithOptions renders the popup content with optional center styling.
func (s *InputComponent) renderPopupWithOptions(centered bool) string {
theme := GetTheme()
popupWidth := max(s.width-4, 20)
popupStyle := lipgloss.NewStyle().
+398
View File
@@ -0,0 +1,398 @@
package ui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
)
// --------------------------------------------------------------------------
// MessageItem implementations for ScrollList
// --------------------------------------------------------------------------
// TextMessageItem represents a completed text message (user or assistant)
// in the scrollback. It uses pre-rendered styled content from MessageRenderer.
type TextMessageItem struct {
id string
role string // "user" or "assistant"
content string // Raw content (for re-rendering if needed)
preRendered string // Pre-rendered styled content from MessageRenderer
timestamp time.Time
}
// NewTextMessageItem creates a new text message for the scrollback.
// The content should be pre-rendered using MessageRenderer for proper styling.
func NewTextMessageItem(id string, role string, content string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: content,
timestamp: time.Now(),
}
}
// NewStyledMessageItem creates a message item with pre-rendered styled content.
// This is the preferred way to create messages when you have styled content from MessageRenderer.
func NewStyledMessageItem(id string, role string, rawContent string, preRendered string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: rawContent,
preRendered: preRendered,
timestamp: time.Now(),
}
}
func (m *TextMessageItem) ID() string {
return m.id
}
func (m *TextMessageItem) Render(width int) string {
// If we have pre-rendered styled content, return it
if m.preRendered != "" {
return m.preRendered
}
// Fallback to simple formatting if no pre-rendered content
return m.renderContent(width)
}
func (m *TextMessageItem) Height() int {
rendered := m.Render(0) // Width doesn't matter since we use pre-rendered
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
func (m *TextMessageItem) renderContent(width int) string {
var parts []string
// Role indicator
if m.role == "user" {
parts = append(parts, "│ ▸ You")
} else {
parts = append(parts, "") // Assistant messages start without role
}
// Content with simple wrapping
contentWidth := max(width-4, 20)
for line := range strings.SplitSeq(m.content, "\n") {
if len(line) <= contentWidth {
parts = append(parts, "│ "+line)
} else {
// Basic wrap
for len(line) > contentWidth {
parts = append(parts, "│ "+line[:contentWidth])
line = line[contentWidth:]
}
if len(line) > 0 {
parts = append(parts, "│ "+line)
}
}
}
return strings.Join(parts, "\n")
}
// --------------------------------------------------------------------------
// StreamingMessageItem - Live streaming assistant/reasoning text
// --------------------------------------------------------------------------
// StreamingMessageItem represents actively streaming assistant or reasoning text.
// It accumulates content chunks and re-renders on each update for live display.
type StreamingMessageItem struct {
id string
role string // "assistant" or "reasoning"
content string // Accumulated streaming content
timestamp time.Time
startTime time.Time // When streaming started (for live duration counter)
modelName string
streaming bool // true while actively streaming
finalDuration time.Duration // Frozen duration when complete
cachedRender string
cachedWidth int
}
// NewStreamingMessageItem creates a new streaming message item.
func NewStreamingMessageItem(id, role string, modelName string) *StreamingMessageItem {
now := time.Now()
return &StreamingMessageItem{
id: id,
role: role,
timestamp: now,
startTime: now,
modelName: modelName,
streaming: true,
}
}
// ID returns the unique identifier.
func (s *StreamingMessageItem) ID() string {
return s.id
}
// Render renders the streaming message with live content.
func (s *StreamingMessageItem) Render(width int) string {
// For reasoning, never cache - we need live duration updates
// For assistant, cache is OK
if s.role != "reasoning" && s.cachedWidth == width && s.cachedRender != "" {
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
if s.finalDuration > 0 {
// Streaming complete, show frozen duration
duration = s.finalDuration
} else if !s.startTime.IsZero() {
// Still streaming, show live duration
duration = time.Since(s.startTime)
}
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"))
} else {
// Render as assistant message
msg := renderer.RenderAssistantMessage(s.content, s.timestamp, s.modelName)
rendered = msg.Content
}
// Cache and return (but reasoning is never cached due to live duration)
if s.role != "reasoning" {
s.cachedRender = rendered
s.cachedWidth = width
}
return rendered
}
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
if s.cachedRender == "" {
return 0
}
return strings.Count(s.cachedRender, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
func (s *StreamingMessageItem) AppendChunk(chunk string) {
s.content += chunk
s.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the streaming message as complete and freezes the duration.
func (s *StreamingMessageItem) MarkComplete() {
s.streaming = false
// Freeze the duration for reasoning blocks
if s.role == "reasoning" && !s.startTime.IsZero() {
s.finalDuration = time.Since(s.startTime)
}
}
// --------------------------------------------------------------------------
// StreamingBashOutputItem - Live bash command output
// --------------------------------------------------------------------------
// StreamingBashOutputItem represents live bash command output.
type StreamingBashOutputItem struct {
id string
command string
stdoutLines []string
stderrLines []string
maxLines int
complete bool
cachedRender string
cachedWidth int
}
// NewStreamingBashOutputItem creates a new streaming bash output item.
func NewStreamingBashOutputItem(id string, command string) *StreamingBashOutputItem {
return &StreamingBashOutputItem{
id: id,
command: command,
stdoutLines: make([]string, 0),
stderrLines: make([]string, 0),
maxLines: 100, // Cap lines to prevent memory issues
complete: false,
}
}
func (m *StreamingBashOutputItem) ID() string {
return m.id
}
func (m *StreamingBashOutputItem) Render(width int) string {
// Return cached if width matches and complete
if m.complete && m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
theme := GetTheme()
var parts []string
// Header with command
if m.command != "" {
headerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
parts = append(parts, headerStyle.Render(fmt.Sprintf("▸ %s", m.command)))
}
const lineIndent = " "
lineWidth := width - len(lineIndent)
// Stdout lines
if len(m.stdoutLines) > 0 {
outputStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stdoutLines {
parts = append(parts, lineIndent+outputStyle.Render(line))
}
}
// Stderr lines
if len(m.stderrLines) > 0 {
stderrStyle := lipgloss.NewStyle().
Foreground(theme.Error).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stderrLines {
parts = append(parts, lineIndent+stderrStyle.Render(line))
}
}
result := strings.Join(parts, "\n")
if m.complete {
m.cachedRender = result
m.cachedWidth = width
}
return result
}
func (m *StreamingBashOutputItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate: command header + stdout + stderr
return 1 + len(m.stdoutLines) + len(m.stderrLines)
}
// AppendStdout adds a stdout line to the output.
func (m *StreamingBashOutputItem) AppendStdout(line string) {
m.stdoutLines = append(m.stdoutLines, line)
// Cap lines
if len(m.stdoutLines) > m.maxLines {
m.stdoutLines = m.stdoutLines[len(m.stdoutLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// AppendStderr adds a stderr line to the output.
func (m *StreamingBashOutputItem) AppendStderr(line string) {
m.stderrLines = append(m.stderrLines, line)
// Cap lines
if len(m.stderrLines) > m.maxLines {
m.stderrLines = m.stderrLines[len(m.stderrLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the bash output as complete.
func (m *StreamingBashOutputItem) MarkComplete() {
m.complete = true
}
// --------------------------------------------------------------------------
// SystemMessageItem - System messages (commands, info, errors)
// --------------------------------------------------------------------------
// SystemMessageItem represents a system message (commands, info, errors).
type SystemMessageItem struct {
id string
content string
timestamp time.Time
cachedRender string
cachedWidth int
}
// NewSystemMessageItem creates a new system message for the scrollback.
func NewSystemMessageItem(id, content string) *SystemMessageItem {
return &SystemMessageItem{
id: id,
content: content,
timestamp: time.Now(),
}
}
func (m *SystemMessageItem) ID() string {
return m.id
}
func (m *SystemMessageItem) Render(width int) string {
// Return cached render if width matches
if m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
// Simple system message formatting
rendered := "│ " + strings.ReplaceAll(m.content, "\n", "\n│ ")
// Cache and return
m.cachedRender = rendered
m.cachedWidth = width
return rendered
}
func (m *SystemMessageItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate
if m.cachedWidth > 0 {
return (len(m.content) / max(m.cachedWidth-10, 40)) + 3
}
return 3
}
// --------------------------------------------------------------------------
// Helper: generateMessageID
// --------------------------------------------------------------------------
var messageCounter = 0
func generateMessageID() string {
messageCounter++
return fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), messageCounter)
}
+30 -16
View File
@@ -3,7 +3,6 @@ package ui
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
@@ -12,9 +11,6 @@ import (
"github.com/indaco/herald"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// MessageType represents different categories of messages displayed in the UI,
// each with distinct visual styling and formatting rules.
type MessageType int
@@ -191,6 +187,36 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderReasoningBlock renders a reasoning/thinking block with the same styling
// 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)
return UIMessage{
Type: AssistantMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderSystemMessage renders KIT system messages using herald Note alert
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
@@ -413,15 +439,3 @@ func createTypography(theme Theme) *herald.Typography {
func (r *MessageRenderer) UpdateTheme() {
r.ty = createTypography(GetTheme())
}
// removeBlankLines removes lines that are visually blank from rendered output.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
+494 -227
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -281,7 +281,14 @@ func (ms *ModelSelectorComponent) View() tea.View {
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the selector is still accepting input.
+2 -3
View File
@@ -5,9 +5,9 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/session"
kit "github.com/mark3labs/kit/pkg/kit"
)
// --------------------------------------------------------------------------
@@ -70,7 +70,7 @@ func (s *stubAppController) AddContextMessage(_ string) {
// no-op in tests
}
func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) int {
func (s *stubAppController) RunWithFiles(prompt string, _ []kit.LLMFilePart) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
@@ -132,7 +132,6 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
stream: stream,
input: input,
renderer: newMessageRenderer(80, false),
compactMode: false,
modelName: "test-model",
width: 80,
height: 24,
+16 -5
View File
@@ -118,22 +118,33 @@ func (m ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// status information and help text. Displays error messages if present or
// a completion message when the download finishes.
func (m ProgressModel) View() tea.View {
var v tea.View
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
if m.err != nil {
return tea.NewView(fmt.Sprintf("Error: %s\n", m.err.Error()))
v.Content = fmt.Sprintf("Error: %s\n", m.err.Error())
return v
}
if m.complete {
return tea.NewView(fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
v.Content = fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
strings.Repeat(" ", padding),
m.progress.View(),
strings.Repeat(" ", padding)))
strings.Repeat(" ", padding))
return v
}
pad := strings.Repeat(" ", padding)
return tea.NewView(fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
v.Content = fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
pad, m.progress.View(),
pad, m.status,
pad+helpStyle("Press 'q' or Ctrl+C to cancel")))
pad+helpStyle("Press 'q' or Ctrl+C to cancel"))
return v
}
// ProgressReader wraps an io.Reader to intercept and parse Ollama pull operation
+629
View File
@@ -0,0 +1,629 @@
package ui
import (
"strings"
"charm.land/lipgloss/v2"
)
// 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 {
// Render returns the styled content for this message at the given width.
// Implementations should cache the result to avoid re-rendering.
Render(width int) string
// Height returns the number of lines this message occupies when rendered.
Height() int
// ID returns a unique identifier for this message (for tracking).
ID() string
}
// 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.
type ScrollList struct {
items []MessageItem
offsetIdx int // Index of first visible item
offsetLine int // Lines to skip from first visible item
width int
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
}
// NewScrollList creates a new ScrollList with the given dimensions.
func NewScrollList(width, height int) *ScrollList {
return &ScrollList{
items: []MessageItem{},
offsetIdx: 0,
offsetLine: 0,
width: width,
height: height,
autoScroll: true, // Start with auto-scroll enabled
}
}
// SetItems replaces the items in the scroll list. If auto-scroll is enabled,
// the viewport will scroll to the bottom to show the latest content.
func (s *ScrollList) SetItems(items []MessageItem) {
s.items = items
if s.autoScroll {
s.GotoBottom()
}
}
// SetHeight updates the viewport height. Called when the terminal is resized.
func (s *ScrollList) SetHeight(height int) {
s.height = height
s.clampOffset()
}
// SetWidth updates the viewport width. Called when the terminal is resized.
// This may invalidate cached renders in MessageItems.
func (s *ScrollList) SetWidth(width int) {
s.width = width
s.clampOffset()
}
// SetItemGap sets the number of blank lines between items (0 = no gap).
func (s *ScrollList) SetItemGap(gap int) {
s.itemGap = gap
}
// ItemGap returns the current gap between items.
func (s *ScrollList) ItemGap() int {
return s.itemGap
}
// SetSelectable enables or disables item selection.
func (s *ScrollList) SetSelectable(selectable bool) {
s.selectable = selectable
}
// 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).
// Returns true if the click was handled.
func (s *ScrollList) HandleMouseDown(x, y int) bool {
if !s.selectable || 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
return true
}
// getItemAndLineAtY converts a Y coordinate to item index and line index within that item.
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
if y < 0 || y >= s.height || len(s.items) == 0 {
return -1, -1
}
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 {
return idx, y - currentY
}
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, -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()
}
// ScrollBy scrolls the viewport by the given number of lines.
// Positive = scroll down, negative = scroll up.
func (s *ScrollList) ScrollBy(lines int) {
if lines > 0 {
// Scroll down
for lines > 0 && s.offsetIdx < len(s.items) {
if s.offsetIdx >= len(s.items) {
break
}
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
remainingLines := itemHeight - s.offsetLine
if lines >= remainingLines {
// Move to next item
s.offsetIdx++
s.offsetLine = 0
lines -= remainingLines
// Consume gap lines between items
if s.itemGap > 0 && s.offsetIdx < len(s.items) {
if lines >= s.itemGap {
lines -= s.itemGap
} else {
lines = 0
}
}
} else {
// Stay on current item, skip more lines
s.offsetLine += lines
lines = 0
}
}
} else if lines < 0 {
// Scroll up
lines = -lines
for lines > 0 && (s.offsetIdx > 0 || s.offsetLine > 0) {
if s.offsetLine > 0 {
// Scroll within current item
if lines >= s.offsetLine {
lines -= s.offsetLine
s.offsetLine = 0
} else {
s.offsetLine -= lines
lines = 0
}
} else if s.offsetIdx > 0 {
// Consume gap lines between items
if s.itemGap > 0 {
if lines > s.itemGap {
lines -= s.itemGap
} else {
lines = 0
continue
}
}
// Move to previous item
s.offsetIdx--
if s.offsetIdx < len(s.items) {
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
if lines >= itemHeight {
lines -= itemHeight
s.offsetLine = 0
} else {
s.offsetLine = itemHeight - lines
lines = 0
}
}
}
}
}
s.clampOffset()
}
// GotoBottom scrolls to the end of the list.
func (s *ScrollList) GotoBottom() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate total height including gaps
totalHeight := 0
for i, item := range s.items {
totalHeight += item.Height()
// Add gap after each item except the last
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
}
// If content fits in viewport, start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Otherwise, position viewport at bottom
remaining := totalHeight - s.height
for idx := 0; idx < len(s.items); idx++ {
itemHeight := s.items[idx].Height()
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
}
}
// Fallback: show last item
s.offsetIdx = max(0, len(s.items)-1)
s.offsetLine = 0
}
// GotoTop scrolls to the beginning of the list.
func (s *ScrollList) GotoTop() {
s.offsetIdx = 0
s.offsetLine = 0
}
// AtBottom returns true if the viewport is at the bottom of the list.
func (s *ScrollList) AtBottom() bool {
if len(s.items) == 0 {
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()
if idx == s.offsetIdx {
visibleHeight += itemHeight - s.offsetLine
} else {
visibleHeight += itemHeight
}
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
visibleHeight += s.itemGap
}
if visibleHeight >= s.height {
return false
}
}
return true
}
// AtTop returns true if the viewport is at the top of the list.
func (s *ScrollList) AtTop() bool {
return s.offsetIdx == 0 && s.offsetLine == 0
}
// 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.
func (s *ScrollList) View() string {
if s.height <= 0 {
return ""
}
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]
content := item.Render(s.width)
contentLines := strings.Split(content, "\n")
startLine := 0
if idx == s.offsetIdx {
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)
}
lines = append(lines, line)
remainingHeight--
}
// Add gap lines between items (but not after the last visible item)
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
lines = append(lines, "")
remainingHeight--
}
}
}
}
// Pad with empty lines to ensure exactly s.height lines
// This keeps the input/footer fixed at the bottom of the screen
for remainingHeight > 0 {
lines = append(lines, "")
remainingHeight--
}
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 {
if len(s.items) == 0 {
return 0.0
}
totalHeight := 0
for _, item := range s.items {
totalHeight += item.Height()
}
if totalHeight <= s.height {
return 1.0 // All content fits, consider it "at bottom"
}
// 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()
}
linesAbove += s.offsetLine
scrollableHeight := totalHeight - s.height
if scrollableHeight <= 0 {
return 1.0
}
percent := float64(linesAbove) / float64(scrollableHeight)
if percent > 1.0 {
percent = 1.0
}
if percent < 0.0 {
percent = 0.0
}
return percent
}
// clampOffset ensures the offset values are within valid bounds after
// resizing or scrolling operations.
func (s *ScrollList) clampOffset() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Clamp offsetIdx
if s.offsetIdx >= len(s.items) {
s.offsetIdx = len(s.items) - 1
}
if s.offsetIdx < 0 {
s.offsetIdx = 0
}
// Clamp offsetLine
if s.offsetIdx < len(s.items) {
itemHeight := s.items[s.offsetIdx].Height()
if s.offsetLine >= itemHeight {
s.offsetLine = max(0, itemHeight-1)
}
}
if s.offsetLine < 0 {
s.offsetLine = 0
}
}
+8 -1
View File
@@ -325,7 +325,14 @@ func (ss *SessionSelectorComponent) View() tea.View {
}
}
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the selector is still accepting input.
+11 -9
View File
@@ -226,7 +226,7 @@ type StreamComponent struct {
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
inThinkTag bool
// renderer renders streaming assistant text in either compact or standard mode.
// renderer renders streaming assistant text.
renderer Renderer
// modelName is displayed in the streaming text header.
@@ -247,17 +247,12 @@ type StreamComponent struct {
}
// NewStreamComponent creates a new StreamComponent ready to be embedded in AppModel.
func NewStreamComponent(compactMode bool, width int, modelName string) *StreamComponent {
func NewStreamComponent(width int, modelName string) *StreamComponent {
if width == 0 {
width = 80
}
var renderer Renderer
if compactMode {
renderer = NewCompactRenderer(width, false)
} else {
renderer = newMessageRenderer(width, false)
}
renderer := newMessageRenderer(width, false)
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
@@ -568,7 +563,14 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (s *StreamComponent) View() tea.View {
fullContent := s.render()
visibleContent := s.viewContent(fullContent)
return tea.NewView(visibleContent)
v := tea.NewView(visibleContent)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// --------------------------------------------------------------------------
+14 -3
View File
@@ -83,9 +83,19 @@ func (t *ToolApprovalInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (t *ToolApprovalInput) View() tea.View {
if t.done {
return tea.NewView("we are done")
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
}
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
@@ -135,5 +145,6 @@ func (t *ToolApprovalInput) View() tea.View {
}
view.WriteString(yesText + "/" + noText + "\n")
return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String())))
v.Content = containerStyle.Render(inputBoxStyle.Render(view.String()))
return v
}
+1 -203
View File
@@ -29,8 +29,7 @@ const (
)
// isShellTool reports if the tool name matches a shell-like tool (bash, grep, find, or
// tools with "shell"/"command" in the name). Used by both renderToolBody and
// renderToolBodyCompact to avoid code duplication.
// tools with "shell"/"command" in the name). Used by renderToolBody.
func isShellTool(toolName string) bool {
return toolName == "bash" || toolName == "grep" || toolName == "find" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command")
@@ -738,183 +737,6 @@ func truncateLine(s string, maxWidth int) string {
return xansi.Truncate(s, maxWidth, "…")
}
// ---------------------------------------------------------------------------
// Compact tool body renderers — one-line summaries for compact mode
// ---------------------------------------------------------------------------
// renderToolBodyCompact returns a brief summary string for tool results in
// compact display mode. Returns empty string to fall back to default.
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
switch {
case toolName == "edit":
return renderEditCompact(toolArgs, toolResult)
case toolName == "ls":
return renderLsCompact(toolResult)
case toolName == "read":
return renderReadCompact(toolResult)
case toolName == "write":
return renderWriteCompact(toolArgs)
case isShellTool(toolName):
return renderBashCompact(toolResult, width)
case toolName == "subagent":
return renderSubagentCompact(toolResult)
}
return ""
}
// renderReadCompact returns a line-count summary for Read tool output.
func renderReadCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
// Count actual code lines (those with "N: " line-number prefix)
codeLines := 0
for _, line := range lines {
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
numPart := line[:idx]
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
codeLines++
}
}
}
if codeLines == 0 {
return ""
}
theme := GetTheme()
summary := fmt.Sprintf("%d lines", codeLines)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderEditCompact returns a change-count summary for Edit tool output.
func renderEditCompact(toolArgs, toolResult string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
oldText, _ := args["old_text"].(string)
newText, _ := args["new_text"].(string)
if oldText == "" && newText == "" {
return ""
}
oldCount := len(strings.Split(oldText, "\n"))
newCount := len(strings.Split(newText, "\n"))
theme := GetTheme()
var summary string
if oldCount == newCount {
summary = fmt.Sprintf("%d lines modified", oldCount)
} else {
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderWriteCompact returns a line-count summary for Write tool output.
func renderWriteCompact(toolArgs string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
content, _ := args["content"].(string)
if content == "" {
return ""
}
count := len(strings.Split(content, "\n"))
theme := GetTheme()
summary := fmt.Sprintf("%d lines written", count)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderLsCompact returns an entry-count summary for Ls tool output.
func renderLsCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
entries := strings.Split(content, "\n")
theme := GetTheme()
summary := fmt.Sprintf("%d entries", len(entries))
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderBashCompact returns the first few lines of bash output as a compact
// summary. Shows up to 3 meaningful output lines.
func renderBashCompact(toolResult string, width int) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
lines := strings.Split(result, "\n")
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
var outputLines []string
var exitCode string
inStderr := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "STDERR:" {
inStderr = true
continue
}
if strings.HasPrefix(trimmed, "Exit code:") {
exitCode = trimmed
continue
}
if trimmed == "" {
continue
}
outputLines = append(outputLines, line)
_ = inStderr // stderr lines are included in output
}
if len(outputLines) == 0 {
if exitCode != "" {
theme := GetTheme()
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return ""
}
const maxLines = 3
theme := GetTheme()
display := outputLines
if len(display) > maxLines {
display = display[:maxLines]
}
// Truncate each line to available width (ANSI-aware)
lineMax := max(width-4, 20)
for i, line := range display {
display[i] = truncateLine(line, lineMax)
}
summary := strings.Join(display, "\n")
if len(outputLines) > maxLines {
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
}
if exitCode != "" {
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
}
// ---------------------------------------------------------------------------
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results with bash-style
// background styling for consistency with other tools.
func renderSubagentBody(toolResult string, width int) string {
@@ -1026,27 +848,3 @@ func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []strin
return preview
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
func renderSubagentCompact(toolResult string) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
theme := GetTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
statusLine := lines[0]
// Make it more compact by removing redundant words
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
}
+8 -1
View File
@@ -265,7 +265,14 @@ func (ts *TreeSelectorComponent) View() tea.View {
footer := fmt.Sprintf("(%d/%d) [%s]", ts.cursor+1, len(ts.flatNodes), ts.filter)
b.WriteString(footerStyle.Render(footer))
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the tree selector is still accepting input.
+8 -4
View File
@@ -134,12 +134,16 @@ kit.Message, kit.MessageRole, kit.ContentPart
kit.TextContent, kit.ReasoningContent, kit.ToolCall, kit.ToolResult, kit.Finish
kit.RoleUser, kit.RoleAssistant, kit.RoleTool, kit.RoleSystem
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// LLM types — concrete Kit-owned structs, no external library dependency
kit.LLMMessage // {Role LLMMessageRole, Content string}
kit.LLMMessageRole // "user" | "assistant" | "system" | "tool"
kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ...}
kit.LLMResponse // {Content, FinishReason, Usage}
kit.LLMFilePart // {Filename, Data []byte, MediaType}
// Conversion helpers
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
msgs := kit.ConvertToLLMMessages(&msg) // SDK Message → []LLMMessage
msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
```
## API Reference
+15 -7
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/compaction"
)
@@ -155,7 +153,15 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
}
model := m.agent.GetModel()
result, _, err := compaction.Compact(ctx, model, messages, *opts, customInstructions, prev)
// Create a streaming callback to emit chunks as events.
streamCallback := func(delta string) error {
// Emit MessageUpdateEvent to the UI for streaming display.
m.events.emit(MessageUpdateEvent{Chunk: delta})
return nil
}
result, _, err := compaction.Compact(ctx, model, messages, *opts, customInstructions, prev, streamCallback)
if err != nil {
return nil, err
}
@@ -181,7 +187,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
// applyCustomCompaction handles compaction when an extension provides a
// custom summary. It still determines the cut point and persists a
// CompactionEntry.
func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message, opts *CompactionOptions) (*CompactionResult, error) {
func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts *CompactionOptions) (*CompactionResult, error) {
originalTokens := compaction.EstimateMessageTokens(messages)
cutPoint := compaction.FindCutPoint(messages, opts.KeepRecentTokens)
@@ -199,9 +205,9 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
}
// Estimate new token count.
summaryTokens := compaction.EstimateMessageTokens([]fantasy.Message{{
Role: "system",
Content: []fantasy.MessagePart{fantasy.TextPart{Text: summary}},
summaryTokens := compaction.EstimateMessageTokens([]LLMMessage{{
Role: LLMRoleSystem,
Content: []LLMMessagePart{LLMTextPart{Text: summary}},
}})
recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:])
compactedTokens := summaryTokens + recentTokens
@@ -249,3 +255,5 @@ func (m *Kit) persistAndEmitCompaction(
})
return nil
}
// Conversion helpers are in llm_convert.go.
+15 -18
View File
@@ -4,7 +4,6 @@ import (
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
@@ -248,19 +247,19 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.ContextPrepare) {
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
// Convert LLM message slice to extension ContextMessage slice.
// Extract plain text from each message for the extension API.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text strings.Builder
var sb strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text.WriteString(tp.Text)
if tp, ok := part.(LLMTextPart); ok {
sb.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text.String(),
Content: sb.String(),
}
}
@@ -271,27 +270,25 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
}
// Rebuild LLM message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
rebuilt := make([]LLMMessage, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
// Reuse original message (preserves tool calls, reasoning, etc.)
// Reuse original message (preserves original role and content).
rebuilt = append(rebuilt, h.Messages[cm.Index])
} else {
// New message injected by extension.
role := fantasy.MessageRoleUser
// New message injected by extension — construct from role + text.
role := LLMRoleUser
switch cm.Role {
case "assistant":
role = fantasy.MessageRoleAssistant
role = LLMRoleAssistant
case "system":
role = fantasy.MessageRoleSystem
role = LLMRoleSystem
case "tool":
role = fantasy.MessageRoleTool
role = LLMRoleTool
}
rebuilt = append(rebuilt, fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: cm.Content},
},
rebuilt = append(rebuilt, LLMMessage{
Role: role,
Content: []LLMMessagePart{LLMTextPart{Text: cm.Content}},
})
}
}
+2 -2
View File
@@ -83,14 +83,14 @@ type AfterTurnResult struct{}
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
type ContextPrepareHook struct {
// Messages is the current context as LLM message objects.
Messages []fantasy.Message
Messages []LLMMessage
}
// ContextPrepareResult can replace the context window.
type ContextPrepareResult struct {
// Messages replaces the entire context window. If nil, the original
// messages are used.
Messages []fantasy.Message
Messages []LLMMessage
}
// BeforeCompactHook is the input for hooks that fire before compaction runs.
+1
View File
@@ -831,6 +831,7 @@ type TurnResult struct {
// Messages is the full updated conversation after the turn, including
// any tool call/result messages added during the agent loop.
// Each message carries role and plain-text content.
Messages []LLMMessage
}
+66 -24
View File
@@ -127,21 +127,74 @@ type ModelsRegistry = models.ModelsRegistry
type SpinnerFunc = agent.SpinnerFunc
// ==== LLM Types ====
//
// These are type aliases for the corresponding charm.land/fantasy types,
// giving them clean LLM-prefixed names without leaking the dependency name.
// SDK consumers can use these types without importing charm.land/fantasy directly.
// LLMMessage is the underlying message type used by the LLM agent
// library. Re-exported so SDK users can work with LLM types without a
// direct import of the underlying LLM library.
// LLMMessage represents a message in an LLM conversation, carrying a role
// and a slice of typed content parts (text, tool calls, reasoning, etc.).
type LLMMessage = fantasy.Message
// LLMUsage contains token usage information from an LLM response.
// LLMMessagePart is the interface implemented by all LLM message content parts.
type LLMMessagePart = fantasy.MessagePart
// LLMFilePart represents a file attachment (image, document, audio, etc.)
// that can be included in a multimodal prompt via PromptResultWithFiles.
type LLMFilePart = fantasy.FilePart
// LLMUsage contains token usage information returned by the LLM provider.
type LLMUsage = fantasy.Usage
// LLMResponse is the response type returned by the LLM agent library.
// LLMResponse represents a complete response from the LLM provider.
type LLMResponse = fantasy.Response
// LLMFilePart represents a file attachment (image, document, etc.) that can
// be included in a prompt via PromptResultWithFiles.
type LLMFilePart = fantasy.FilePart
// LLMTextPart is a plain-text content part for constructing LLM messages.
type LLMTextPart = fantasy.TextPart
// LLMReasoningPart is a reasoning/chain-of-thought content part.
type LLMReasoningPart = fantasy.ReasoningPart
// LLMToolCallPart represents an LLM-initiated tool invocation within a message.
type LLMToolCallPart = fantasy.ToolCallPart
// LLMToolResultPart represents the result of a tool execution within a message.
type LLMToolResultPart = fantasy.ToolResultPart
// LLMToolResultOutputContent is the interface for tool result output content.
type LLMToolResultOutputContent = fantasy.ToolResultOutputContent
// LLMToolResultOutputContentText is a text-valued tool result output.
type LLMToolResultOutputContentText = fantasy.ToolResultOutputContentText
// LLMToolResultOutputContentError is an error-valued tool result output.
type LLMToolResultOutputContentError = fantasy.ToolResultOutputContentError
// LLMMessageRole identifies the participant role in an LLM conversation.
type LLMMessageRole = fantasy.MessageRole
// LLMFinishReason indicates why the LLM stopped generating.
type LLMFinishReason = fantasy.FinishReason
// LLM role constants mirror fantasy.MessageRole* values under clean LLM-prefixed names.
const (
// LLMRoleUser identifies a user message.
LLMRoleUser = fantasy.MessageRoleUser
// LLMRoleAssistant identifies an assistant message.
LLMRoleAssistant = fantasy.MessageRoleAssistant
// LLMRoleSystem identifies a system message.
LLMRoleSystem = fantasy.MessageRoleSystem
// LLMRoleTool identifies a tool result message.
LLMRoleTool = fantasy.MessageRoleTool
)
// NewLLMUserMessage constructs a user-role LLMMessage with optional file
// attachments. It is equivalent to fantasy.NewUserMessage.
var NewLLMUserMessage = fantasy.NewUserMessage
// NewLLMSystemMessage constructs a system-role LLMMessage from one or more
// prompt strings. It is equivalent to fantasy.NewSystemMessage.
var NewLLMSystemMessage = fantasy.NewSystemMessage
// ==== Compaction Types (internal/compaction/) ====
@@ -177,24 +230,13 @@ func LoadSystemPrompt(pathOrContent string) (string, error) {
// ==== Conversion Helpers ====
// ConvertToLLMMessages converts an SDK message to the underlying LLM
// messages used by the agent for LLM interactions.
func ConvertToLLMMessages(msg *Message) []fantasy.Message {
// ConvertToLLMMessages converts an SDK message to a slice of LLMMessages.
// Each SDK message may expand to multiple LLM messages depending on its content.
func ConvertToLLMMessages(msg *Message) []LLMMessage {
return msg.ToLLMMessages()
}
// ConvertFromLLMMessage converts an LLM message from the agent to an SDK
// message format for use in the SDK API.
func ConvertFromLLMMessage(msg fantasy.Message) Message {
// ConvertFromLLMMessage converts an LLMMessage to an SDK message.
func ConvertFromLLMMessage(msg LLMMessage) Message {
return message.FromLLMMessage(msg)
}
// Deprecated: Use ConvertToLLMMessages instead.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return ConvertToLLMMessages(msg)
}
// Deprecated: Use ConvertFromLLMMessage instead.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return ConvertFromLLMMessage(msg)
}
+216
View File
@@ -1,6 +1,7 @@
package kit_test
import (
"encoding/json"
"testing"
kit "github.com/mark3labs/kit/pkg/kit"
@@ -59,3 +60,218 @@ func TestTypeExports(t *testing.T) {
t.Errorf("round-trip Content() = %q, want %q", roundTrip.Content(), "test")
}
}
// TestLLMRoleConstants verifies the LLM role constants have the correct values.
func TestLLMRoleConstants(t *testing.T) {
if kit.LLMRoleUser != "user" {
t.Errorf("LLMRoleUser = %q, want %q", kit.LLMRoleUser, "user")
}
if kit.LLMRoleAssistant != "assistant" {
t.Errorf("LLMRoleAssistant = %q, want %q", kit.LLMRoleAssistant, "assistant")
}
if kit.LLMRoleSystem != "system" {
t.Errorf("LLMRoleSystem = %q, want %q", kit.LLMRoleSystem, "system")
}
if kit.LLMRoleTool != "tool" {
t.Errorf("LLMRoleTool = %q, want %q", kit.LLMRoleTool, "tool")
}
}
// TestLLMMessageAlias verifies LLMMessage is a type alias for fantasy.Message
// and can be used interchangeably.
func TestLLMMessageAlias(t *testing.T) {
// Construct an LLMMessage using alias types.
msg := kit.LLMMessage{
Role: kit.LLMRoleUser,
Content: []kit.LLMMessagePart{
kit.LLMTextPart{Text: "hello world"},
},
}
if msg.Role != "user" {
t.Errorf("LLMMessage.Role = %q, want %q", msg.Role, "user")
}
// Verify we can extract text via the part types.
if len(msg.Content) != 1 {
t.Fatalf("expected 1 content part, got %d", len(msg.Content))
}
tp, ok := msg.Content[0].(kit.LLMTextPart)
if !ok {
t.Fatal("content part is not LLMTextPart")
}
if tp.Text != "hello world" {
t.Errorf("LLMTextPart.Text = %q, want %q", tp.Text, "hello world")
}
}
// TestNewLLMUserMessage verifies the NewLLMUserMessage constructor works.
func TestNewLLMUserMessage(t *testing.T) {
msg := kit.NewLLMUserMessage("hello from user")
if msg.Role != kit.LLMRoleUser {
t.Errorf("NewLLMUserMessage role = %q, want %q", msg.Role, kit.LLMRoleUser)
}
if len(msg.Content) == 0 {
t.Fatal("NewLLMUserMessage content is empty")
}
tp, ok := msg.Content[0].(kit.LLMTextPart)
if !ok {
t.Fatal("content[0] is not LLMTextPart")
}
if tp.Text != "hello from user" {
t.Errorf("NewLLMUserMessage text = %q, want %q", tp.Text, "hello from user")
}
}
// TestNewLLMSystemMessage verifies the NewLLMSystemMessage constructor works.
func TestNewLLMSystemMessage(t *testing.T) {
msg := kit.NewLLMSystemMessage("you are helpful")
if msg.Role != kit.LLMRoleSystem {
t.Errorf("NewLLMSystemMessage role = %q, want %q", msg.Role, kit.LLMRoleSystem)
}
if len(msg.Content) == 0 {
t.Fatal("NewLLMSystemMessage content is empty")
}
}
// TestLLMUsageAlias verifies LLMUsage is a type alias for fantasy.Usage
// and carries the correct fields.
func TestLLMUsageAlias(t *testing.T) {
u := kit.LLMUsage{
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
ReasoningTokens: 10,
CacheCreationTokens: 5,
CacheReadTokens: 20,
}
if u.InputTokens != 100 {
t.Errorf("LLMUsage.InputTokens = %d, want 100", u.InputTokens)
}
if u.TotalTokens != 150 {
t.Errorf("LLMUsage.TotalTokens = %d, want 150", u.TotalTokens)
}
// Verify JSON marshaling uses snake_case (inherited from fantasy.Usage tags).
data, err := json.Marshal(u)
if err != nil {
t.Fatalf("LLMUsage.MarshalJSON: %v", err)
}
jsonStr := string(data)
if jsonStr == "" {
t.Error("LLMUsage JSON is empty")
}
// Check that input_tokens key is present.
if !containsStr(jsonStr, `"input_tokens":100`) {
t.Errorf("LLMUsage JSON missing input_tokens: %s", jsonStr)
}
}
// TestLLMFilePartAlias verifies LLMFilePart is a type alias for fantasy.FilePart.
func TestLLMFilePartAlias(t *testing.T) {
fp := kit.LLMFilePart{
Filename: "screenshot.png",
Data: []byte{0x89, 0x50, 0x4E, 0x47},
MediaType: "image/png",
}
if fp.Filename != "screenshot.png" {
t.Errorf("LLMFilePart.Filename = %q, want %q", fp.Filename, "screenshot.png")
}
if fp.MediaType != "image/png" {
t.Errorf("LLMFilePart.MediaType = %q, want %q", fp.MediaType, "image/png")
}
if len(fp.Data) != 4 {
t.Errorf("LLMFilePart.Data len = %d, want 4", len(fp.Data))
}
// Verify it can be used as a file part for constructing user messages.
msg := kit.NewLLMUserMessage("see this image", fp)
if msg.Role != kit.LLMRoleUser {
t.Errorf("message role = %q, want user", msg.Role)
}
}
// TestLLMPartTypesAlias verifies all the part type aliases compile and work.
func TestLLMPartTypesAlias(t *testing.T) {
// LLMTextPart
tp := kit.LLMTextPart{Text: "plain text"}
if tp.Text != "plain text" {
t.Errorf("LLMTextPart.Text = %q", tp.Text)
}
// LLMReasoningPart
rp := kit.LLMReasoningPart{Text: "I think therefore"}
if rp.Text != "I think therefore" {
t.Errorf("LLMReasoningPart.Text = %q", rp.Text)
}
// LLMToolCallPart
tc := kit.LLMToolCallPart{
ToolCallID: "call-1",
ToolName: "bash",
Input: `{"cmd":"echo hi"}`,
}
if tc.ToolCallID != "call-1" {
t.Errorf("LLMToolCallPart.ToolCallID = %q", tc.ToolCallID)
}
// LLMToolResultPart
tro := kit.LLMToolResultOutputContentText{Text: "output text"}
tr := kit.LLMToolResultPart{
ToolCallID: "call-1",
Output: tro,
}
if tr.ToolCallID != "call-1" {
t.Errorf("LLMToolResultPart.ToolCallID = %q", tr.ToolCallID)
}
}
// TestConvertToLLMMessages verifies round-trip conversion preserves content.
func TestConvertToLLMMessages(t *testing.T) {
msg := kit.Message{
Role: kit.RoleUser,
Parts: []kit.ContentPart{kit.TextContent{Text: "what is 2+2?"}},
}
llmMsgs := kit.ConvertToLLMMessages(&msg)
if len(llmMsgs) == 0 {
t.Fatal("ConvertToLLMMessages returned empty slice")
}
if llmMsgs[0].Role != kit.LLMRoleUser {
t.Errorf("converted Role = %q, want %q", llmMsgs[0].Role, kit.LLMRoleUser)
}
// Check text is preserved in content parts.
found := false
for _, part := range llmMsgs[0].Content {
if tp, ok := part.(kit.LLMTextPart); ok && tp.Text == "what is 2+2?" {
found = true
}
}
if !found {
t.Errorf("text content not found in converted LLMMessage")
}
}
// TestConvertFromLLMMessage verifies LLMMessage → Message conversion.
func TestConvertFromLLMMessage(t *testing.T) {
llm := kit.NewLLMUserMessage("the answer is 4")
llm.Role = kit.LLMRoleAssistant
msg := kit.ConvertFromLLMMessage(llm)
if msg.Role != kit.RoleAssistant {
t.Errorf("converted Role = %q, want %q", msg.Role, kit.RoleAssistant)
}
if msg.Content() != "the answer is 4" {
t.Errorf("converted Content() = %q, want %q", msg.Content(), "the answer is 4")
}
}
// containsStr is a tiny helper to avoid importing strings in test.
func containsStr(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && indexStr(s, substr) >= 0)
}
func indexStr(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
ACP smoke test drives `kit acp` over JSON-RPC 2.0 stdio.
Protocol flow:
1. session/new get sessionId
2. session/set_model set opencode/kimi-k2.5
3. session/prompt "What is 2+2? Answer in one sentence."
4. Collect session updates until done
"""
import json
import subprocess
import sys
import threading
import time
import os
KIT_BIN = os.path.join(os.path.dirname(__file__), "..", "output", "kit")
MODEL = "opencode/kimi-k2.5"
CWD = os.path.expanduser("~")
TIMEOUT = 60 # seconds to wait for the prompt to complete
def rpc(method, params, req_id):
return json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
def send(proc, line):
print(f"\n→ SEND {line.strip()}", flush=True)
proc.stdin.write(line)
proc.stdin.flush()
def read_responses(proc, collected, done_event):
"""Read newline-delimited JSON from stdout until process exits."""
for raw in proc.stdout:
raw = raw.strip()
if not raw:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
print(f" [non-JSON stdout]: {raw}", flush=True)
continue
collected.append(msg)
# Pretty-print condensed
if "result" in msg:
result = msg["result"]
print(f"← RESP id={msg.get('id')} result={json.dumps(result)[:200]}", flush=True)
# Prompt complete when we get a stopReason on id=3
if msg.get("id") == 3 and "stopReason" in result:
done_event.set()
elif "error" in msg:
print(f"← ERROR id={msg.get('id')} {json.dumps(msg['error'])}", flush=True)
# If it's the prompt call that errored, unblock
if msg.get("id") == 3:
done_event.set()
elif "method" in msg:
# Notification / session update
m = msg.get("method", "")
p = msg.get("params", {})
if m in ("session/update", "session/updated"):
update = p.get("update", {})
stype = update.get("sessionUpdate") or update.get("type", "?")
content = update.get("content", {})
if stype == "agent_thought_chunk":
print(f" [thinking] {content.get('text','')}", end="", flush=True)
elif stype == "agent_message_chunk":
print(f" [response] {content.get('text','')}", end="", flush=True)
else:
print(f"\n [update/{stype}] {json.dumps(update)[:200]}", flush=True)
else:
print(f"\n← NOTIF {m} {json.dumps(p)[:200]}", flush=True)
def main():
print(f"Starting: {KIT_BIN} acp -m {MODEL}", flush=True)
proc = subprocess.Popen(
[KIT_BIN, "acp", "-m", MODEL],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
collected = []
done_event = threading.Event()
reader = threading.Thread(target=read_responses, args=(proc, collected, done_event), daemon=True)
reader.start()
stderr_lines = []
def read_stderr():
for line in proc.stderr:
line = line.rstrip()
stderr_lines.append(line)
if line:
print(f" [stderr] {line}", flush=True)
threading.Thread(target=read_stderr, daemon=True).start()
time.sleep(0.3) # let the process initialise
# 1. session/new
send(proc, rpc("session/new", {"cwd": CWD, "mcpServers": []}, 1))
time.sleep(1.0)
session_id = None
for msg in collected:
if msg.get("id") == 1 and "result" in msg:
session_id = msg["result"].get("sessionId")
break
if not session_id:
print("\n✗ FAIL: did not get sessionId from session/new", flush=True)
proc.terminate()
sys.exit(1)
print(f"\n✓ Got sessionId: {session_id}", flush=True)
# 2. session/set_model (model already set via -m flag, but exercise the RPC)
send(proc, rpc("session/set_model", {"sessionId": session_id, "modelId": MODEL}, 2))
time.sleep(0.5)
# 3. session/prompt
prompt_params = {
"sessionId": session_id,
"prompt": [{"type": "text", "text": "What is 2+2? Answer in one sentence."}],
}
send(proc, rpc("session/prompt", prompt_params, 3))
# Wait for finished update or timeout
if not done_event.wait(timeout=TIMEOUT):
print(f"\n✗ FAIL: timed out after {TIMEOUT}s waiting for finished update", flush=True)
proc.terminate()
sys.exit(1)
# Check we got a successful prompt response
prompt_resp = next((m for m in collected if m.get("id") == 3), None)
if prompt_resp and "error" in prompt_resp:
print(f"\n✗ FAIL: prompt returned error: {prompt_resp['error']}", flush=True)
proc.terminate()
sys.exit(1)
print("\n✓ SMOKE TEST PASSED", flush=True)
proc.terminate()
proc.wait(timeout=5)
if __name__ == "__main__":
main()
+12 -6
View File
@@ -120,15 +120,17 @@ result, err := host.PromptResult(ctx, "Analyze this file")
// result.StopReason — "stop", "length", "tool-calls", "error", etc.
// result.SessionID — session UUID
// result.TotalUsage — aggregate tokens across all steps (*kit.LLMUsage)
// result.FinalUsage — tokens from last API call only
// LLMUsage{InputTokens, OutputTokens, TotalTokens, ...}
// result.FinalUsage — tokens from last API call only (*kit.LLMUsage)
// result.Messages — full updated conversation ([]kit.LLMMessage)
// LLMMessage{Role kit.LLMMessageRole, Content string}
```
### Multimodal with file attachments
```go
files := []kit.LLMFilePart{{
Name: "screenshot.png",
Filename: "screenshot.png",
MediaType: "image/png",
Data: imageBytes,
}}
@@ -640,15 +642,19 @@ kit.Config, kit.MCPServerConfig
// Provider types
kit.ProviderConfig, kit.ProviderResult, kit.ModelInfo, kit.ModelCost, kit.ModelLimit
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// LLM types — concrete Kit-owned structs (no external library dependency)
kit.LLMMessage // {Role LLMMessageRole, Content string}
kit.LLMMessageRole // "user" | "assistant" | "system" | "tool"
kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ReasoningTokens, ...}
kit.LLMResponse // {Content, FinishReason, Usage}
kit.LLMFilePart // {Filename, Data []byte, MediaType}
// Compaction types
kit.CompactionResult, kit.CompactionOptions
// Conversion helpers
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
msgs := kit.ConvertToLLMMessages(&msg) // SDK Message []LLMMessage
msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
```
---
-450
View File
@@ -1,450 +0,0 @@
# Unified Bubble Tea Architecture
## Overview
Replace the micro-program pattern (3 interactive `tea.NewProgram` calls + 1 standalone progress) with a single persistent Bubble Tea program using child model composition. Extract a thick app layer from `cmd/root.go` to own agent orchestration, message storage, and event emission. TUI becomes purely reactive.
New capabilities: message queueing during streaming, double-tap ESC cancellation, stacked layout (output above, input pinned below), queue badge with clear support.
## User Story
As a KIT user, I want the TUI to remain responsive during agent streaming so I can queue follow-up messages, cancel in-progress work, and see a persistent input area -- instead of waiting for each response to complete before typing.
As a developer, I want the TUI architecture to follow Bubble Tea's idiomatic child-model pattern so components are composable, testable, and extensible without terminal ownership conflicts.
## Requirements
### Architecture
- Single `tea.NewProgram()` call for the entire interactive session
- Parent model manages state transitions and routes messages to child components
- Child components: `InputComponent` (slash commands + autocomplete), `StreamComponent` (streaming display + spinner), `ApprovalComponent` (tool approval)
- Ollama `ProgressModel` remains standalone (different lifecycle, runs during provider init)
- Non-interactive mode bypasses `tea.Program` entirely, uses same app layer without TUI
### App Layer
- New `internal/app` package owns: agent orchestration loop, in-memory message store, message queue, tool approval callback, hook execution, session persistence, usage tracking
- App layer exposes `Run(prompt)`, `RunOnce(ctx, prompt)`, `CancelCurrentStep()`, `ClearQueue()`, `QueueLength()`, `ClearMessages()`
- Events sent to TUI via `program.Send()` -- no pubsub infra
- Message store: mutable `[]fantasy.Message` with wrapper IDs, emits events on change. Bridges to `session.Manager` for persistence on each step completion.
- `ToolApprovalFunc` provided at construction via `Options`. Interactive mode: channel handshake with TUI. Non-interactive: auto-approve. Channel must be `select`-able against app context to avoid goroutine leaks on shutdown.
- All 7 agent callbacks from `GenerateWithLoopAndStreaming` (`agent.go:144-151`) mapped to events sent via `program.Send()`. See Events section.
- Hook executor (`hooks.Executor`) owned by app layer. Fires `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` at same points as current `runAgenticStep`.
### App Layer Options
Full options mirroring current `AgenticLoopConfig` (`root.go:753-769`):
```go
type Options struct {
Agent *agent.Agent
ToolApprovalFunc ToolApprovalFunc // required, set at construction
HookExecutor *hooks.Executor // optional
SessionManager *session.Manager // optional, for persistence
MCPConfig *config.Config // for session continuation
ModelName string
ServerNames []string // for slash commands
ToolNames []string // for slash commands
StreamingEnabled bool
Quiet bool
Debug bool
CompactMode bool
}
```
### Events
Events emitted by app layer (defined in `internal/app/events.go`):
| Event | Source callback | Purpose |
|---|---|---|
| `StreamChunkEvent` | `onStreamingResponse` | Streaming text delta |
| `ToolCallStartedEvent` | `onToolCall` | Tool call initiated (name + args) |
| `ToolExecutionEvent` | `onToolExecution` | Tool execution starting/stopping |
| `ToolResultEvent` | `onToolResult` | Tool result (name, args, result, isError) |
| `ToolCallContentEvent` | `onToolCallContent` | Tool call content display |
| `ResponseCompleteEvent` | `onResponse` | Final response text |
| `StepCompleteEvent` | (after generate returns) | Agent step finished, includes usage data |
| `StepErrorEvent` | (on agent error) | Agent step failed with error |
| `QueueUpdatedEvent` | (on queue change) | Queue length changed |
| `ToolApprovalNeededEvent` | `onToolApproval` | Approval required, includes response channel |
| `SpinnerEvent` | (before first chunk) | Show/hide spinner state |
| `HookBlockedEvent` | (hook returns block) | Hook blocked the action |
| `MessageCreatedEvent` | (on history add) | New message added to store |
TUI-internal messages (defined in `internal/ui/events.go`, NOT in app layer):
| Message | Purpose |
|---|---|
| `submitMsg` | Input component submitted text |
| `approvalResultMsg` | Approval component returned decision |
| `cancelTimerExpiredMsg` | 2s ESC timer expired |
### TUI Behavior
- Stacked layout: latest response output above, input textarea pinned below
- Output area shows latest response only. Completed responses emitted above the BT-managed region via `tea.Println()` before the model resets for the next interaction. This works with BT v2 inline mode (no alt screen).
- Input textarea keeps current sizing behavior from `SlashCommandInput`
- Slash command autocomplete fully self-contained in input component. Component holds `*app.App` reference for executing commands that affect app state (`/clear` calls `app.ClearMessages()`, `/quit` returns `tea.Quit` to parent, `/clear-queue` calls `app.ClearQueue()`). Parent receives either a `submitMsg` (text prompt) or a `tea.Cmd` (slash command side effect).
- Message queueing: user can submit while agent streams. Queue badge shows "N queued" near input. `/clear-queue` slash command flushes queue.
- Double-tap ESC: first press shows "press again to cancel", second press calls `App.CancelCurrentStep()`. Timer expires after 2s, resets state.
- Tool approval: agent blocks on `ToolApprovalFunc` callback. Callback sends `ToolApprovalNeededEvent` (containing a `chan<- bool` response channel) to program, then blocks on that channel via `select` with `ctx.Done()`. TUI transitions to approval state, user decides, parent sends result on channel. If ctx cancelled, callback returns `false, ctx.Err()`.
- Keyboard during streaming: input textarea remains focused and editable. All keystrokes go to the input component normally. ESC is intercepted by parent for cancel flow. Enter/submit queues the message via `app.Run()`.
- Spinner: `StreamComponent` renders a spinner animation (replacing the current standalone goroutine-based `ui.Spinner`) when the agent is processing but hasn't sent any chunks yet. First `StreamChunkEvent` transitions from spinner to streaming display. No more goroutine writing to stderr.
### Compact Mode
Current code uses two renderers (`MessageRenderer` and `CompactRenderer`) toggled by `cli.compactMode`. Both renderers retained. The `CompactMode` flag propagated through `App.Options` → parent model → child components. Each component checks the flag and delegates to the appropriate renderer for message formatting.
### Usage Tracking
`UsageTracker` moves to the app layer. Created during `App.New()` using model info from `Options`. App layer calls `UpdateUsageFromResponse()` after each step. Emits usage data in `StepCompleteEvent`. TUI renders usage via retained `UsageTracker.RenderUsageInfo()` method. Non-interactive mode reads usage from app layer directly.
### Non-Interactive Mode
- Same app layer, no TUI. `ToolApprovalFunc` auto-approves (provided at construction). Output prints directly to stdout.
- Current `runNonInteractiveMode` refactored to use `app.RunOnce()`.
- **Behavior change**: current non-interactive non-quiet mode creates a BT streaming display program. New behavior: `RunOnce()` accepts an optional `StreamingWriter io.Writer` for real-time output. Non-interactive passes `os.Stdout`. No BT program created.
### Session Persistence
`session.Manager` owned by app layer (passed via `Options.SessionManager`). App layer calls `session.Manager.AddMessages()` after each step completion and on queue drain. `--load-session` flag handled in `cmd/root.go` before app construction -- loaded messages passed to `App.New()` as initial history. `MessageStore.Clear()` also calls `session.Manager.ReplaceAllMessages()`.
### Error Handling
Agent errors (API failures, rate limits, MCP crashes) emitted as `StepErrorEvent`. Parent model receives the event, passes error to `StreamComponent` for inline display (matching current behavior), then transitions to `stateInput`. No automatic retry -- user can retry by submitting again.
### Graceful Shutdown
Shutdown sequence when user quits (Ctrl+C or `/quit`):
1. Parent model returns `tea.Quit`
2. `tea.Program.Run()` returns in `cmd/root.go`
3. If agent goroutine running: `app.CancelCurrentStep()` called (deferred)
4. `app.Close()` called (deferred) -- cancels app context, waits for agent goroutine to exit
5. `mcpAgent.Close()` called (deferred, existing) -- closes MCP connections and provider
`App` holds a top-level `context.Context` (created with `context.WithCancel` in `New()`). All agent goroutines use this context. `App.Close()` cancels it and calls `sync.WaitGroup.Wait()` to ensure clean exit.
### Parent Model State Machine
```
stateInput ──submit──→ stateWorking ──StepComplete──→ stateInput
│ ↑
├──ToolApproval──→ stateApproval──approve/deny──┘
│ │
├──StepError────→ stateInput │
│ │
└──Cancel────────→ stateInput │
(queue non-empty: auto-drain) ───┘
```
States:
- `stateInput` -- input focused, waiting for user
- `stateWorking` -- agent running (spinner → streaming → tool calls → streaming → ...)
- `stateApproval` -- tool approval dialog active (sub-state of working)
### Testing
- Unit tests for each child component (send messages, assert state transitions)
- Unit tests for parent model (state routing, child delegation, cancel flow, error handling)
- Unit tests for app layer (message store, queue, cancel, session save ordering, ToolApprovalFunc channel + ctx cancellation)
## Technical Implementation
### Package Structure
```
internal/
app/
app.go # App struct, New(), Run(), RunOnce(), CancelCurrentStep(), Close()
app_test.go # App tests (queue, cancel, drain, session save)
messages.go # MessageStore (in-memory, wraps []fantasy.Message, bridges session.Manager)
messages_test.go # MessageStore tests
events.go # All event types sent to TUI via program.Send()
options.go # Options struct, ToolApprovalFunc type
ui/
model.go # Parent tea.Model (AppModel), state machine, message routing
model_test.go # Parent model tests
input.go # InputComponent (refactored slash_command_input.go)
input_test.go # Input tests
stream.go # StreamComponent (refactored streaming_display.go + spinner)
stream_test.go # Stream tests
approval.go # ApprovalComponent (refactored tool_approval_input.go)
approval_test.go # Approval tests
events.go # TUI-internal message types (submitMsg, approvalResultMsg, cancelTimerExpiredMsg)
cli.go # Retained: SetupCLI factory (creates App + AppModel), non-TUI helpers
messages.go # Retained: message rendering (used by StreamComponent)
styles.go # Retained
enhanced_styles.go # Retained
compact_renderer.go # Retained
block_renderer.go # Retained
commands.go # Retained + /clear-queue added
fuzzy.go # Retained
usage_tracker.go # Retained (used by app layer)
debug_logger.go # Retained (used by app layer)
ui/progress/
ollama.go # Retained standalone (not part of refactor)
```
### Parent Model
```go
type appState int
const (
stateInput appState = iota // Input focused, waiting for user
stateWorking // Agent running, streaming output
stateApproval // Tool approval dialog active
)
type AppModel struct {
state appState
app *app.App // Thick app layer reference
input InputComponent // Child: user input + autocomplete
stream StreamComponent // Child: streaming display + spinner
approval ApprovalComponent // Child: tool approval
renderer *MessageRenderer // For tea.Println of completed responses
compactRdr *CompactRenderer // Compact mode renderer
compactMode bool // Which renderer to use
queueCount int // Cached from QueueUpdatedEvent
canceling bool // Double-tap ESC state
approvalChan chan<- bool // Response channel for current approval
width int
height int
}
```
### Event Flow
```
User types → InputComponent.Update() → submit
Parent receives submitMsg → calls app.Run(prompt) in tea.Cmd goroutine
Parent transitions to stateWorking → StreamComponent active (spinner mode)
App layer goroutine: agent processes
→ program.Send(SpinnerEvent{Show: true})
→ program.Send(ToolCallStartedEvent{...})
→ program.Send(StreamChunkEvent{...}) (first chunk hides spinner)
→ program.Send(ToolResultEvent{...})
→ program.Send(ToolCallContentEvent{...})
Parent routes events to StreamComponent.Update()
Agent needs tool approval → ToolApprovalFunc called
→ creates chan bool, sends ToolApprovalNeededEvent{ResponseChan: ch}
→ blocks: select { case result := <-ch; case <-ctx.Done() }
Parent stores channel in approvalChan, transitions to stateApproval
User approves → Parent sends on approvalChan → Agent continues
Agent completes → app sends StepCompleteEvent{Usage: ...}
Parent: tea.Println() completed response, transitions to stateInput
If queue non-empty: App auto-drains next message, stays in stateWorking
```
### Cancel Flow
```
User presses ESC during stateWorking
Parent sets canceling=true, returns cancelTimerCmd (2s tea.Tick)
User presses ESC again within 2s → Parent calls app.CancelCurrentStep()
App cancels step context → agent goroutine exits
→ ToolApprovalFunc unblocks via ctx.Done() if waiting
→ StepErrorEvent or StepCompleteEvent emitted
Parent transitions to stateInput
cancelTimerExpiredMsg arrives (if no second ESC) → resets canceling=false
```
### cmd/root.go Changes
```go
// runNormalMode becomes:
appInstance, err := app.New(app.Options{
Agent: mcpAgent,
ToolApprovalFunc: toolApprovalFunc, // set per mode, see below
HookExecutor: hookExecutor,
SessionManager: sessionManager,
MCPConfig: mcpConfig,
ModelName: modelString,
ServerNames: serverNames,
ToolNames: toolNames,
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}, initialMessages) // loaded from session if --load-session
defer appInstance.Close()
// Interactive mode:
toolApprovalFunc = app.NewInteractiveApprovalFunc(appInstance)
model := ui.NewAppModel(appInstance, uiOpts)
program := tea.NewProgram(model)
appInstance.SetProgram(program) // Safe: app.Run() not called until Init()
_, err := program.Run()
// Non-interactive mode:
toolApprovalFunc = app.AutoApproveFunc
result, err := appInstance.RunOnce(ctx, prompt, os.Stdout) // stdout for streaming
printResult(result)
```
**SetProgram timing**: Safe because `app.Run()` is only called from `tea.Cmd` functions after the program starts its event loop. `AppModel.Init()` returns no command that calls `app.Run()` -- the first `Run()` call happens when the user submits input or when `Init()` dispatches an initial prompt (non-interactive continuation via `--no-exit`).
## Tasks
### 1. Create app layer skeleton
- [ ] [P0] Create `internal/app/events.go` with all event types: `StreamChunkEvent`, `ToolCallStartedEvent`, `ToolExecutionEvent`, `ToolResultEvent`, `ToolCallContentEvent`, `ResponseCompleteEvent`, `StepCompleteEvent` (with usage data), `StepErrorEvent`, `QueueUpdatedEvent`, `ToolApprovalNeededEvent` (with `ResponseChan chan<- bool`), `SpinnerEvent`, `HookBlockedEvent`, `MessageCreatedEvent`
- [ ] [P0] Create `internal/app/options.go` with `Options` struct (all fields from App Layer Options section), `ToolApprovalFunc` type (`func(ctx context.Context, toolName, toolArgs string) (bool, error)`), `AutoApproveFunc` var, `NewInteractiveApprovalFunc` constructor
- [ ] [P0] Create `internal/app/messages.go` with `MessageStore` wrapping `[]fantasy.Message`. Methods: `Add(fantasy.Message)`, `Replace([]fantasy.Message)`, `GetAll() []fantasy.Message`, `Clear()`. Bridges to `session.Manager` (if non-nil) on every mutation for persistence.
- [ ] [P0] Create `internal/app/app.go` with `App` struct, `New(opts, initialMessages)`, `SetProgram(*tea.Program)`, `Run(prompt)`, `RunOnce(ctx, prompt, io.Writer)`, `CancelCurrentStep()`, `QueueLength()`, `ClearQueue()`, `ClearMessages()`, `Close()`. Internal: `context.WithCancel`, `sync.WaitGroup`, `sync.Mutex` for busy/queue state.
### 2. Migrate agent orchestration into app layer
- [ ] [P1] Move `runAgenticStep` logic from `cmd/root.go:873-1191` into `App.executeStep()`. Map all 7 agent callbacks to `program.Send()` events. Wire `ToolApprovalFunc` for `onToolApproval`. Emit `SpinnerEvent{Show:true}` before calling agent, `SpinnerEvent{Show:false}` on first stream chunk.
- [ ] [P1] Move hook execution from `cmd/root.go:810-828,943-969,1002-1019,1186-1223` into `App.executeStep()`. Fire `UserPromptSubmit` in `Run()` before `executeStep()`. Fire `PreToolUse`/`PostToolUse`/`Stop` at same points. Emit `HookBlockedEvent` if hook blocks.
- [ ] [P1] Move conversation history management into `MessageStore`. `App.executeStep()` calls `store.Add()` for user message before agent call, `store.Replace()` with updated history after agent returns. Store bridges to `session.Manager`.
- [ ] [P1] Move usage tracking into app layer. Create `UsageTracker` in `App.New()` from model info. Call `UpdateUsageFromResponse()` after each step. Include usage data in `StepCompleteEvent`.
- [ ] [P1] Implement queue drain: after step completes (success or error), if queue non-empty, dequeue next message and call `executeStep()` in same goroutine (no new goroutine spawn).
### 3. Create parent TUI model
- [ ] [P1] Create `internal/ui/model.go` with `AppModel` struct (see Parent Model section), `NewAppModel()`, `Init()`, `Update()`, `View()`. State machine routes events to children based on `appState`. Handle `tea.WindowSizeMsg` to distribute height. Store `approvalChan` for tool approval response.
- [ ] [P1] Implement double-tap ESC cancel in parent `Update()`: intercept `tea.KeyPressMsg` for ESC during `stateWorking`. Track `canceling` bool, return `tea.Tick(2*time.Second, ...)` as timer cmd, call `app.CancelCurrentStep()` on second press within window.
- [ ] [P1] Implement `tea.Println()` for completed responses: on `StepCompleteEvent`, render the completed response using message renderer (respecting compact mode), emit via `tea.Println()`, then reset `StreamComponent` state.
- [ ] [P1] Implement `StepErrorEvent` handling: render error inline in stream area, transition to `stateInput`.
- [ ] [P1] Implement graceful quit: Ctrl+C and `/quit` return `tea.Quit`. Deferred `app.Close()` in `cmd/root.go` handles cleanup.
### 4. Refactor child components
- [ ] [P1] Refactor `slash_command_input.go``internal/ui/input.go` as `InputComponent`. Remove `tea.Quit` on submit -- return `submitMsg` as a `tea.Cmd`. Keep autocomplete + popup self-contained. Hold `*app.App` reference for slash command execution: `/clear``app.ClearMessages()`, `/clear-queue``app.ClearQueue()`, `/quit` → return `tea.Quit` cmd. Remove `os.Exit(0)` from `/quit`.
- [ ] [P1] Refactor `streaming_display.go``internal/ui/stream.go` as `StreamComponent`. Add spinner state: render KITT-style animation (from current `spinner.go`) when `SpinnerEvent{Show:true}` received, switch to streaming text on first `StreamChunkEvent`. Accept all display events (`ToolCallStartedEvent`, `ToolResultEvent`, etc.) and render via retained `MessageRenderer`/`CompactRenderer`. Remove `streamDoneMsg`/`tea.Quit` -- parent manages lifecycle. Add `Reset()` to clear state between steps.
- [ ] [P1] Refactor `tool_approval_input.go``internal/ui/approval.go` as `ApprovalComponent`. Remove `tea.Quit` -- return `approvalResultMsg{approved: bool}` as a `tea.Cmd`. Parent handles sending result on `approvalChan`.
### 5. Wire TUI to app layer in cmd/root.go
- [ ] [P1] Refactor `runNormalMode()`: create `app.App` with full `Options` (all fields). Wire `ToolApprovalFunc` per mode. Load session messages before construction. Defer `appInstance.Close()`.
- [ ] [P1] Interactive path: create `ui.NewAppModel()` + single `tea.NewProgram(model)` + `appInstance.SetProgram(program)` + `program.Run()`. Remove `SetupCLI()` flow for interactive mode.
- [ ] [P1] Non-interactive path: call `appInstance.RunOnce(ctx, prompt, os.Stdout)`. Handle `--no-exit` by switching to interactive mode after. No `tea.Program` created. Remove old streaming display usage for non-interactive.
- [ ] [P1] Retain `SetupCLI()` as alternative factory for non-interactive quiet mode (just prints final text, no renderers needed). Or inline the quiet-mode logic.
### 6. Implement message queueing UX
- [ ] [P2] Add queue badge rendering in parent `View()` -- show "N queued" right-aligned on separator line when `queueCount > 0`. Update count on `QueueUpdatedEvent`.
- [ ] [P2] Register `/clear-queue` slash command in `internal/ui/commands.go`.
- [ ] [P2] Handle `submitMsg` during `stateWorking`: parent calls `app.Run()` (which queues internally), does NOT transition state. Input component stays active and clears text.
### 7. Stacked layout
- [ ] [P2] Implement stacked `View()` in parent: stream output region (variable height) + separator line + input region (current textarea height). Use `lipgloss.JoinVertical`. Separator shows queue badge if applicable.
- [ ] [P2] Handle `tea.WindowSizeMsg` propagation: calculate input height (fixed, from textarea), separator (1 line), remaining goes to stream. Propagate dimensions to children.
### 8. Cleanup
- [ ] [P2] Delete standalone `tea.NewProgram` calls from `cli.go` (`GetPrompt`, `StartStreamingMessage`, `GetToolApproval`). Remove `streamProgram`/`streamDone` fields.
- [ ] [P2] Delete `runAgenticStep`, `runAgenticLoop`, `runInteractiveLoop`, `addMessagesToHistory`, `replaceMessagesHistory`, `AgenticLoopConfig` from `cmd/root.go`.
- [ ] [P2] Delete old `spinner.go` (replaced by StreamComponent's inline spinner).
- [ ] [P3] Trim `CLI` struct to only non-TUI helpers needed by non-interactive quiet mode. Remove `GetPrompt`, `StartStreamingMessage`, `UpdateStreamingMessage`, `GetToolApproval`, `finishStreaming`, `HandleSlashCommand`. Retain `DisplayError`, `DisplayInfo` for non-interactive error output if needed, or remove entirely if `RunOnce` handles its own output.
### 9. Tests
- [ ] [P2] Unit tests for `MessageStore`: add, replace, getAll, clear, session.Manager bridge (mock manager, verify calls)
- [ ] [P2] Unit tests for `App`: run (single), run (queued), cancel during step, cancel during approval (verify ToolApprovalFunc unblocks via ctx), queue drain ordering, ClearQueue, Close (verify goroutine cleanup via WaitGroup)
- [ ] [P2] Unit tests for `AppModel`: state transitions (input→working→approval→input), StepError→input, ESC cancel flow (single tap resets, double tap cancels), queue badge update, window resize, tea.Println on step complete
- [ ] [P2] Unit tests for child components: `InputComponent` (submit emits submitMsg, slash commands execute, /quit returns tea.Quit), `StreamComponent` (spinner→streaming transition, chunk accumulation, tool call rendering, reset), `ApprovalComponent` (approve/deny emits approvalResultMsg)
## UI Mockup
### Processing (stateWorking, spinner)
```
◇◇◇◆◇◇◇ Thinking...
───────────────────────────────────
> █
```
### During Streaming (stateWorking)
```
assistant (claude-sonnet-4-20250514)
Here is the implementation of the requested
feature. First, I'll create the new file...
█ (streaming cursor)
─────────────────────────────────── 2 queued
> write tests for that too█
```
### Tool Call in Stream (stateWorking)
```
assistant (claude-sonnet-4-20250514)
Let me check the build first.
⚙ bash: go build -o output/kit
◇◇◇◆◇◇◇ Executing...
─────────────────────────────────── 2 queued
> write tests for that too█
```
### During Tool Approval (stateApproval)
```
assistant (claude-sonnet-4-20250514)
I need to run a command to check the build.
┌─ Tool Approval ──────────────────────┐
│ bash: go build -o output/kit │
│ │
│ [Yes] No │
└──────────────────────────────────────┘
─────────────────────────────────── 2 queued
> █
```
### Cancel in Progress (stateWorking, canceling)
```
assistant (claude-sonnet-4-20250514)
Analyzing the codebase structure to find
relevant files...
⚠ Press ESC again to cancel
─────────────────────────────────── 1 queued
> also check the tests█
```
### Error (stateWorking → stateInput)
```
✗ Error: API rate limit exceeded. Try again.
───────────────────────────────────
> █
```
## Out of Scope
- Scrollable viewport / chat history browsing (latest response only for v1)
- Ollama `ProgressModel` unification (stays standalone)
- Persistent message storage / database (session JSON files retained as-is)
- Multi-session support
- Split-pane or tabbed layouts
- Mouse interaction
- Changing tool call display format (keep current rendering via retained renderers)
- Prompt history persistence across sessions
- Any visual/theme changes beyond new layout
- Refactoring the `agent.Agent` or `fantasy` interfaces
- Changing hook execution semantics
## Open Questions
- Should queue drain be immediate (next message starts as soon as current step completes) or should there be a brief pause to let the user read the response?
- If the user cancels mid-stream and there are queued messages, should the queue also be flushed or should the next queued message execute?
- Should the input component retain focus (cursor visible, editable) during `stateApproval`, or should focus fully transfer to the approval dialog?
- Should `tea.Println()` of completed responses include tool call/result details, or just the final assistant text? Current behavior shows everything inline.
- How should debug logging work during the TUI lifecycle? Currently `BufferedDebugLogger` accumulates messages shown after agent creation. In the new architecture, should debug messages be events rendered in the stream component?
- For `--no-exit` (non-interactive then interactive): should `RunOnce` return and then `cmd/root.go` creates the TUI program for the interactive continuation, or should the TUI program be created upfront and the initial prompt dispatched via `Init()`?
+9
View File
@@ -0,0 +1,9 @@
1. Hello, world!
2. Testing one, two, three.
3. This is a quick test message.
4. Sample text for verification.
5. All systems operational.