Compare commits

...

40 Commits

Author SHA1 Message Date
Ed Zynda aecce001ee feat(mcp): add OAuth support for remote MCP servers
- Add MCPAuthHandler interface at SDK level (pkg/kit/) so all consumers
  (CLI, TUI, SDK embedders) control the OAuth UX through one interface
- Default handler opens system browser + local callback server with PKCE
- CLIMCPAuthHandler wraps default with status messages (stderr pre-TUI,
  system messages via TUI event system once running)
- Always enable OAuth on remote transports (streamable HTTP, SSE) when
  handler is configured; harmless for servers that don't need it
- Dynamic client registration when no client ID is pre-configured
- File-based TokenStore persists tokens to ~/.config/.kit/mcp_tokens.json
  keyed by server URL so users don't re-auth on restart
- Catch OAuthAuthorizationRequiredError at connection init (startup) and
  tool execution (mid-session token expiry), run auth flow, retry once
- Fix error wrapping (%v -> %w) in connection pool so errors.As can
  unwrap through the chain to find OAuth errors
- Thread AuthHandler through MCPToolManager -> AgentConfig ->
  AgentCreationOptions -> AgentSetupOptions -> kit.Options
2026-04-04 17:41:57 +03:00
Ed Zynda 32d73171fd fix(extensions): write manifest Include in single pass and preserve on update
- InstallWithInclude wrote manifest twice via two different code paths,
  with the first write missing Include; unify into shared install() method
  that writes the manifest once with all fields including Include
- Update() now reads the existing manifest entry to preserve Include and
  Installed timestamp instead of constructing a fresh entry from scratch
2026-04-04 17:19:00 +03:00
Ed Zynda 265fd2ec0c fix(extensions): skip _test.go files and non-extension examples/ subdirs
- Filter out _test.go files in findExtensionsInDir, findExtensionsInRepo,
  and ScanForExtensions to prevent Yaegi from loading test files
- Narrow examples/ traversal so only recognized extension directories
  (extensions/, ext/, *-ext/, *-extensions/) are scanned, not arbitrary
  subdirs like examples/sdk/ that import pkg/kit
2026-04-04 16:44:13 +03:00
Ed Zynda efebf2eba6 fix(kit-telegram): add typing indicator and config fallback to global path
- Send sendChatAction("typing") every 4s while agent is processing,
  started on AgentStart and stopped on AgentEnd/SessionShutdown
- configPath() now checks project-local .kit/ first, then falls back
  to ~/.config/kit/kit-telegram.json for cross-project portability
2026-04-04 16:33:08 +03:00
Ed Zynda f7b655ae33 feat(extensions): add Abort, IsIdle, Compact, SendMultimodalMessage, GetSessionUsage to Context
- ctx.Abort(): cancel current agent turn and clear queue without
  injecting a new message (App.Abort + App.IsBusy methods)
- ctx.IsIdle(): check whether the agent is currently processing
- ctx.Compact(CompactConfig): trigger async context compaction with
  OnComplete/OnError callbacks (App.CompactAsync method)
- ctx.SendMultimodalMessage(text, []FilePart): send text+image messages
  to the agent, bridging ext.FilePart to fantasy.FilePart via RunWithFiles
- ctx.GetSessionUsage() SessionUsage: expose aggregated session token
  usage and cost from the UsageTracker

New types: CompactConfig, FilePart, SessionUsage
Wired in both context setups in cmd/root.go with nil-guard defaults
in runner.go and Yaegi symbol exports in symbols.go
2026-04-04 15:01:02 +03:00
Ed Zynda 35982b41ad fix(pkg): transparently handle <think> tags in stream
Move reasoning tag detection from the provider and UI layers into the agent layer. This prevents raw XML tags from leaking into text streams while ensuring structured reasoning events are emitted correctly for all callers.
2026-04-03 13:49:12 +03:00
Ed Zynda 788e3b71fd feat(config): per-model baseUrl and apiKey for custom models
- Add `baseUrl` and `apiKey` fields to CustomModelConfig (config and models packages)
- Store them on ModelInfo so they travel through the registry
- createCustomProvider resolves URL/key from model definition first,
  falling back to global --provider-url / --provider-api-key
- Fix registry initialisation: call ReloadGlobalRegistry() in InitConfig()
  so customModels from config are visible on startup (not just at init time)
- Include custom provider in GetLLMProviders() so custom models appear
  in the /model selector
- Hide the built-in custom/custom stub from the selector when user-defined
  custom models are present
2026-04-03 12:37:14 +03:00
Ed Zynda 3496bc2684 feat(ui): add bordered container and improved styling to session selector
- Add full-width bordered container with rounded border and primary color
- Add max height constraint to prevent terminal overflow
- Improve selection highlighting with inverted colors matching PopupList style
- Change cursor indicator from › to > for consistency
- Add separator lines between header, content, and footer
- Add footer showing current filter mode
2026-04-02 17:20:55 +03:00
Ed Zynda 997c7d15ff fix: include pasted images in steering messages
Steering messages (Ctrl+S during agent work) now carry file attachments
just like queued messages do. Previously, pasted images were silently
dropped when steering.

Changes:
- Add SteerMessage struct with Text and Files fields
- Update steer channel from chan string to chan SteerMessage
- Add SteerWithFiles methods through the stack (UI, app, SDK)
- Update PrepareStep to include files in injected user messages
2026-04-02 17:19:34 +03:00
Ed Zynda 83246e47d5 feat(ui): add bordered container and improved styling to tree selector
- Add full-width bordered container with rounded border and primary color
- Add max height constraint to prevent terminal overflow
- Improve selection highlighting with inverted colors matching PopupList style
- Change cursor indicator from › to > for consistency
- Use MutedBorder for tree lines and Success color for active marker
- Update search display format to match PopupList (
2026-04-02 17:18:16 +03:00
Ed Zynda 50e7b78c33 fix(ui): strip herald CodeBlock padding to fix mouse selection off-by-one
Herald's codeBlockWithLineNumbers() hardcodes PaddingTop(1) and
PaddingBottom(1), adding invisible blank lines with background color
above and below the code content. These padding lines occupy line
indices in the rendered item but are visually indistinguishable from
empty space, causing mouse click coordinates to map to the wrong
content line (consistently 1 row off in tool output blocks).

Strip the padding lines after CodeBlock rendering since the Compose
separator above and Figure caption below already provide adequate
visual spacing.
2026-04-02 16:49:44 +03:00
Ed Zynda b937af3056 refactor(ui): use herald Figure component for grep tool output
Add dedicated renderGrepBody function for the grep tool, replacing the
previous behavior of routing it through renderBashBody. The grep tool now:

- Shows a caption with total match count (e.g., '8 matches' or '1 match')
- Displays truncation info when matches exceed maxLsLines
- Uses consistent Figure component styling with ls, read, find, and bash tools
- Uses 'match/matches' terminology appropriate for grep results
2026-04-02 16:12:48 +03:00
Ed Zynda a5e995c750 refactor(ui): use herald Figure component for find tool output
Add dedicated renderFindBody function for the find tool, replacing the
previous behavior of routing it through renderBashBody. The find tool now:

- Shows a caption with total result count (e.g., '12 results')
- Displays truncation info when results exceed maxLsLines
- Uses consistent Figure component styling with ls, read, and bash tools
2026-04-02 16:11:49 +03:00
Ed Zynda e95e08a699 refactor(ui): use herald Figure component for ls tool output
Apply the same Figure component pattern to the ls tool for consistency
with read and bash tools. The caption now appears below the directory
listing and shows the count of hidden entries when truncated.
2026-04-02 16:10:00 +03:00
Ed Zynda bcaf92f62a refactor(ui): use herald Figure component for read and bash tool output
Replace inline truncation hints and exit code labels with herald's
Figure component. Captions now appear below content and show:

- read: filename • lines X-Y of Z • offset=N to continue
- bash: N more lines • exit code N

This provides consistent visual grouping and cleaner metadata
display for tool output blocks.
2026-04-02 16:09:17 +03:00
Ed Zynda ead4afbfe6 fix(subagent): prevent instant failure from already-dead parent contexts
- Replace detachedWithCancel (goroutine-based) with context.WithoutCancel
  + valuesContext; the old goroutine would fire immediately if the parent
  was already cancelled/deadline-exceeded, causing 'failed after 0s'
- Kit.Subagent() pre-flight: if the incoming ctx is already done, reset
  to context.Background() before applying the subagent timeout
- Both Subagent() error paths now return a non-nil *SubagentResult with
  Elapsed set, so the tool response always shows accurate timing
- Narrow viperInitMu scope in Kit.New(): snapshot viper state + call
  BuildProviderConfig under the lock, then release before SetupAgent /
  MCP loading; parallel subagent spawns no longer serialise on viper I/O
- AgentSetupOptions gains ProviderConfig + scalar fields so SetupAgent
  can skip viper reads when a pre-built config is supplied
- Add subagent_test.go covering the fixed context detachment behaviour
2026-04-02 15:54:47 +03:00
Ed Zynda 685aaf207f feat(extensions): add hot-reload with file watching and /reload-ext command
- Add fsnotify-based file watcher that auto-reloads extensions on .go
  file changes in autoloaded dirs with 300ms debounce
- Add /reload-ext built-in command (alias /re) for manual reload
- Add Agent.SetExtraTools() so extension tools update on reload
  instead of being baked in at agent creation time
- Run reload async via tea.Cmd to avoid prog.Send() deadlock when
  extension handlers call ctx.Print() during SessionStart/Shutdown
- Wire watcher lifecycle into cmd/root.go with graceful shutdown
2026-04-02 15:41:54 +03:00
Ed Zynda 76ff6c9639 style(ui): segment KITT scanner LEDs and center logo text
- Break scanner bar into individual LED segments with single-space gaps
- Center KIT text over the scanner bar (13-space indent for all lines)
- Maintain original 46-char total width for the scanner bar
2026-04-02 15:11:01 +03:00
Ed Zynda 1cf24ee5de fix(core): return error when read tool is used on a directory
- Return an error response guiding the agent to use ls instead
- Remove unused readDirectory helper function
2026-04-02 14:45:33 +03:00
Ed Zynda c9637090fa feat(subagent): return early error for invalid model instead of silent fallback
- Add ValidateModelString() to ModelsRegistry for format, provider,
  and model name validation with typo suggestions
- Validate model in Kit.Subagent() before expensive Kit.New() setup
- Remove silent fallback to parent model on creation failure
- Error propagates as tool result so calling agent can self-correct
- Add registry_test.go covering format, provider, and suggestion cases
2026-04-02 14:45:03 +03:00
Ed Zynda 0ff0ff42ab fix(ui): wrap tool error output in caution alert block
Prevent tool error text from spilling into the surrounding layout
by rendering it inside a herald Caution alert container.
2026-04-02 14:39:29 +03:00
Ed Zynda a4fb32ff2b feat(ui): add reusable PopupList and render /model as overlay
- Add PopupList: generic themed popup with fuzzy search, scrolling,
  keyboard navigation, and centered overlay rendering
- Refactor ModelSelectorComponent to delegate to PopupList instead
  of implementing its own full-screen rendering and input handling
- Render /model selector as a centered overlay on top of the chat
  view instead of replacing the entire screen
- PopupList accepts a pluggable FilterFunc for domain-specific
  fuzzy matching (model selector wires its own scoring)
- Add 11 tests for PopupList covering navigation, search, selection,
  cancellation, filtering, rendering, and edge cases
2026-04-02 14:39:21 +03:00
Ed Zynda 7d2f078111 fix(ui): freeze reasoning counter when last token is processed
- Wire fantasy's OnReasoningEnd callback through the full event chain:
  agent → SDK (ReasoningCompleteEvent) → app → TUI
- Freeze reasoning duration in both StreamComponent and
  StreamingMessageItem as soon as reasoning ends, not when the
  next assistant text chunk arrives
- Fix accent color on duration label in render.ReasoningBlock to
  match the live streaming style (VeryMuted prefix + Accent duration)
2026-04-02 14:18:42 +03:00
Ed Zynda b0b66941ab fix(extensions): batch go-edit-lint per turn and fix OnAgentEnd StopReason docs
- Refactor go-edit-lint to collect edited .go files during the agent
  turn via OnToolResult, then run gopls + golangci-lint once in
  OnAgentEnd instead of after every individual edit/write call
- Use ctx.SendMessage() to inject diagnostics as a follow-up prompt
  when issues are found, replacing the old tool-result rewriting
- Show a green 'all clean' block when no issues are detected
- Fix StopReason docs in skills/kit-extensions/SKILL.md: the value is
  'error' on failure, 'completed' when the LLM returns empty, or the
  raw provider value (e.g. 'stop', 'end_turn') passed through — not
  the previously documented 'completed'/'cancelled'/'error' enum
2026-04-02 14:04:41 +03:00
Ed Zynda cbb7387a72 fix(test): add return after t.Fatal to silence SA5011 nil-deref warnings
- internal/ui/model_test.go: bashItem nil check
- pkg/extensions/test/harness_test.go: footer and result nil checks
2026-04-01 21:24:02 +03:00
Ed Zynda 19430b0ecb chore(ui): remove dead toast and clipboard code
Remove 8 unused exports from clipboard package:
- CopyToClipboardWithMessage, IsClipboardSupported
- ToastMsg, ToastType, ToastInfo, ToastSuccess, ToastWarning, ToastError

These were remnants of a toast notification feature that was never
wired up. No callers exist anywhere in the codebase.
2026-04-01 21:11:00 +03:00
Ed Zynda 8e3cfeede5 fix(ui): correct mouse selection Y-offset for reasoning blocks
The getItemAndLineAtY() method was using item.Height() which returns 0
for reasoning blocks (StreamingMessageItem with role='reasoning') because
their render cache is intentionally never populated (they include a live
duration timer).

This caused all items below a reasoning block to have incorrect Y
coordinates — clicking on the reasoning text would highlight the
assistant text below it instead.

Two fixes:
1. getItemAndLineAtY() now uses renderedHeight() which calls Render()
   and counts lines — matching exactly what View() does. This is the
   single source of truth for item height during hit-testing.

2. StreamingMessageItem.Height() now falls back to Render(0) when
   cachedRender is empty, fixing the same issue for other callers
   (GotoBottom, ScrollBy, clampOffset, etc.).
2026-04-01 18:15:04 +03:00
Ed Zynda 4fa5775974 feat(ui): implement character-level mouse text selection and copy
Implement crush-style mouse text selection with character-level precision,
replacing the previously disabled stub implementation.

Architecture:
- New selection package (internal/ui/selection/) handles all coordinate
  math, word boundary detection, and cell-level ANSI text manipulation
- ScrollList upgraded with proper mouse down/drag/up flow supporting
  single click (character drag), double click (word), triple click (line)
- Model.go wires BubbleTea mouse events through to ScrollList with
  proper viewport Y-offset adjustment for the scrollback area

Key features:
- Character-level selection using ultraviolet ScreenBuffer for ANSI-aware
  cell parsing — correctly handles styled text, emoji, CJK wide chars
- Word selection via UAX#29 Unicode segmentation (clipperhouse/uax29)
- Display-width-aware columns via clipperhouse/displaywidth (not bytes)
- Dual clipboard: OSC 52 (remote terminals) + native (atotto/clipboard)
- Multi-click detection with 400ms threshold and 2px tolerance
- Mouse event throttling via existing MouseModeCellMotion
- Selection cleared on any keypress for clean UX

Dependencies (all already indirect in go.mod):
- github.com/charmbracelet/ultraviolet (ScreenBuffer, cell manipulation)
- github.com/charmbracelet/x/ansi (ANSI strip, StringWidth)
- github.com/clipperhouse/displaywidth (grapheme display width)
- github.com/clipperhouse/uax29/v2 (Unicode word segmentation)
2026-04-01 18:05:48 +03:00
Ed Zynda 4e7d823ee4 feat(ui): make /fork create new session file matching Pi behavior
- Add ForkToNewSession method to create new session with history up to target
- Add NewTreeSelectorForFork showing only user messages (flat list)
- Update performFork to create and switch to new session file
- Update /fork command description in docs and help text

Previously /fork just branched within the same session file like /tree.
Now /fork creates a completely new session file with parent_session reference,
matching Pi's behavior exactly.
2026-04-01 16:10:55 +03:00
Ed Zynda 7a16c76adc fix(ui): trim whitespace when loading session messages to prevent empty blocks
When loading session history, some assistant messages contain text parts
with only whitespace (e.g., single space ' '). These were being rendered
as empty message blocks, causing extra vertical spacing in the UI.

Fix by trimming whitespace from message content before checking if it's
non-empty in renderSessionHistory().

Changes:
- Apply strings.TrimSpace() to user message content before rendering
- Apply strings.TrimSpace() to assistant message content before rendering

This prevents empty/whitespace-only message blocks from being added to
the scrollback when resuming sessions.
2026-04-01 15:11:42 +03:00
Ed Zynda 70a21ee73a refactor(ui): extract shared message rendering functions
Extract pure rendering functions into internal/ui/render/blocks.go
to eliminate code duplication between streaming and historical
message rendering paths.

Changes:
- Create render package with UserBlock, AssistantBlock, ReasoningBlock,
  SystemBlock, ErrorBlock, and ToolBlock functions
- Update MessageRenderer methods to use shared render functions
- Update StreamingMessageItem to use shared render functions
- Reduce ~77 lines of duplicated code across message_items.go and messages.go

All existing tests pass, no functional changes.
2026-04-01 14:59:27 +03:00
Ed Zynda 28d2de8f39 Phase 1: Reorganize UI leaf utilities into subpackages
Moved leaf utility files to subpackages for better organization:
- events.go -> core/ (core message types)
- clipboard.go -> clipboard/ (clipboard operations)
- commands.go -> commands/ (slash commands)
- file_processor.go -> fileutil/ (file attachment processing)
- preferences.go -> prefs/ (theme/model preferences)
- enhanced_styles.go, styles.go, themes.go -> style/ (theming system)

Added exports.go to re-export commonly used types for backward
compatibility. External importers can still use ui.XXX without
changes.

All tests pass, basic smoke test successful.
2026-04-01 13:54:10 +03:00
Ed Zynda 7f192ae850 feat(ui): improve slash command popup contrast with full-width backgrounds
- Change border from MutedBorder to Primary for visibility
- Add full-width background styles for all popup items
- Use inverse colors for selected item (primary bg, background fg)
- Add background to scroll indicators and footer
- Add bottom margin for visual depth/shadow effect
2026-04-01 13:35:20 +03:00
Ed Zynda 9f6746ded9 fix(ui): re-enable auto-scroll on new message submission
Auto-scroll was being disabled when users manually scrolled (mouse wheel,
PgUp, etc.) but never re-enabled. Now it reactivates when submitting a
new message so the conversation view jumps to the bottom to show the
latest content.
2026-04-01 13:29:40 +03:00
Ed Zynda 7514d3a0ff chore(deps): update go and npm dependencies
- github.com/indaco/herald v0.10.0 → v0.11.0
- github.com/indaco/herald-md v0.1.0 → v0.2.0
- google.golang.org/api v0.273.0 → v0.273.1
- google.golang.org/genai v1.52.0 → v1.52.1
- google.golang.org/grpc v1.79.3 → v1.80.0
- gonum.org/v1/gonum v0.16.0 → v0.17.0
- add npm and www package-lock.json files
2026-04-01 13:24:36 +03:00
Ed Zynda c83281a52b docs: add feature-request prompt for GitHub feature requests
Add a dedicated /feature-request prompt that guides users through creating
well-formed feature requests using the GitHub feature_request template.

The prompt focuses on:
- Problem-first description
- Clear motivation and use cases
- Optional proposed implementation
- Conventional commit-style titles (feat: ...)

Usage: /feature-request <description of the feature>
2026-04-01 13:22:14 +03:00
Ed Zynda 4515bb92c2 docs: update file-issue prompt to use GitHub issue templates
The file-issue prompt now references the structured GitHub issue templates
(bug_report, feature_request, documentation) and guides users to use the
--template flag with gh issue create for consistent issue formatting.
2026-04-01 13:21:20 +03:00
Ed Zynda e326b84204 chore: add GitHub issue templates and file-issue prompt
Add structured GitHub issue templates for:
- Bug reports (with reproduction steps, code, component)
- Feature requests (with motivation and proposed implementation)
- Documentation issues

Also add a /file-issue kit prompt for quickly filing issues from the TUI.

The templates enforce conventional commit-style titles and include
checklists to ensure issues are well-formed before submission.
2026-04-01 13:20:43 +03:00
Ed Zynda 1b93049b8e fix(ui): remove j/k navigation from fuzzy selectors
Remove 'j' and 'k' keybindings from model, session, and tree selectors
to allow typing those characters for fuzzy filtering. Navigation now
uses only arrow keys (↑/↓) which matches the existing help text.
2026-04-01 13:11:44 +03:00
Ed Zynda 4912449dda fix(ui): render selectors in alt screen buffer
Fix /resume, /model, and /tree selectors to render in the alternate
screen buffer instead of terminal scrollback. All three selector
components now set AltScreen=true on their tea.View returns.
2026-04-01 13:09:23 +03:00
87 changed files with 12859 additions and 1633 deletions
+79
View File
@@ -0,0 +1,79 @@
name: Bug Report
description: Report a bug or issue with Kit
title: "fix: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Bug Description
description: What happened? What did you expect to happen?
placeholder: |
The BorderColor field in ToolRenderConfig is documented but never applied
during tool rendering. I expected the tool block to render with my custom
color, but it uses the default styling instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Provide clear steps to reproduce the issue
placeholder: |
1. Create an extension with `api.RegisterToolRenderer(ext.ToolRenderConfig{...})`
2. Set `BorderColor: "#89b4fa"` in the config
3. Run a tool that uses this renderer
4. Observe the border color is not applied
render: markdown
validations:
required: true
- type: textarea
id: code
attributes:
label: Relevant Code / Configuration
description: Paste any code, configuration, or error messages
placeholder: |
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
BorderColor: "#a6e3a1", // This is ignored!
Background: "#1e1e2e", // This is ignored!
})
```
render: go
- type: input
id: component
attributes:
label: Affected Component
description: Which part of Kit is affected?
placeholder: e.g., extensions, ui, tool rendering, session management
- type: input
id: version
attributes:
label: Kit Version
description: What version of Kit are you running?
placeholder: e.g., v0.1.0, commit hash, or "main"
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, proposed fixes, or related issues
placeholder: |
The issue appears to be in `internal/ui/messages.go:RenderToolMessage()`
which ignores the BorderColor and Background fields from ToolRendererData.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been reported yet
required: true
- label: I've tested with the latest version of Kit
required: false
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Kit Documentation
url: https://github.com/mark3labs/kit/tree/main/www/pages
about: Check the documentation before filing an issue
- name: Extension Examples
url: https://github.com/mark3labs/kit/tree/main/examples/extensions
about: See working extension examples for reference
- name: Discussions
url: https://github.com/mark3labs/kit/discussions
about: For questions, ideas, or general discussion
+40
View File
@@ -0,0 +1,40 @@
name: Documentation Issue
description: Report missing, incorrect, or unclear documentation
title: "docs: "
labels: ["documentation"]
body:
- type: textarea
id: description
attributes:
label: Documentation Issue
description: What's wrong or missing in the documentation?
placeholder: |
The ToolRenderConfig documentation mentions BorderColor and Background fields,
but the code doesn't actually use them. The docs should either be updated
to reflect reality, or the bug should be fixed.
validations:
required: true
- type: input
id: location
attributes:
label: Documentation Location
description: Where is the affected documentation?
placeholder: e.g., README.md, examples/extensions/tool-renderer-demo.go, pkg/kit docs
- type: textarea
id: suggestion
attributes:
label: Suggested Improvement
description: How should the documentation be improved?
placeholder: |
Add a note that BorderColor and Background are not yet implemented,
or fix the bug and document the correct behavior.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've checked that this documentation issue still exists in the latest version
required: true
@@ -0,0 +1,64 @@
name: Feature Request
description: Suggest a new feature or enhancement for Kit
title: "feat: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Feature Description
description: What would you like to see added or changed?
placeholder: |
I'd like to be able to customize the border color of tool result blocks
dynamically based on the tool type or result status.
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why is this feature needed? What problem does it solve?
placeholder: |
When running multiple tools in sequence, it's hard to visually distinguish
between file reads (blue), shell commands (green), and errors (red)
without custom border colors.
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed Implementation
description: How do you think this should work? (optional)
placeholder: |
Extend `ToolRenderConfig` to accept a function that receives the tool
result and returns a color based on the content:
```go
BorderColorFunc: func(result string, isError bool) string {
if isError {
return "#f38ba8"
}
return "#89b4fa"
}
```
render: go
- type: checkboxes
id: alternatives
attributes:
label: Alternatives Considered
options:
- label: I've considered workarounds or alternative approaches
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been requested yet
required: true
- label: This feature aligns with Kit's design philosophy (TUI-first, extension-based)
required: false
+74 -34
View File
@@ -28,11 +28,15 @@ type lintResult struct {
Err error
}
// Package-level state: set of .go files edited during the current agent turn.
var editedFiles map[string]bool
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint on Go file edits")
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint after agent turns that edit Go files")
})
// Track edited .go files — don't lint yet.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
if e.IsError || !isEditOrWrite(e.ToolName) {
return nil
@@ -43,30 +47,72 @@ func Init(api ext.API) {
return nil
}
report := runGoDiagnostics(ctx.CWD, absPath)
// Check if there are issues and add explicit prompt for the LLM to react
goplsIssues, lintIssues := countIssues(report)
hasIssues := goplsIssues > 0 || lintIssues > 0
var enhanced string
if hasIssues {
enhanced = e.Content + "\n\n" + report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review the issues above and fix them before proceeding."
} else {
enhanced = e.Content + "\n\n" + report
if editedFiles == nil {
editedFiles = make(map[string]bool)
}
editedFiles[absPath] = true
return nil
})
// After the agent turn ends, lint all collected files.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if len(editedFiles) == 0 {
return
}
// Show TUI message block for diagnostics visibility (only if there are issues)
// Snapshot and reset immediately so the next turn starts clean.
files := editedFiles
editedFiles = nil
// Skip lint on errored turns.
if e.StopReason == "error" {
return
}
// Collect unique directories and file list for gopls.
var allGoplsOutput []string
for absPath := range files {
res := runGopls(ctx.CWD, absPath)
formatted := formatToolResult(res, "")
if formatted != "" {
allGoplsOutput = append(allGoplsOutput, fmt.Sprintf("# %s\n%s", filepath.Base(absPath), formatted))
}
}
lintRes := runGolangCILint(ctx.CWD, "./...")
goplsSection := "No diagnostics."
if len(allGoplsOutput) > 0 {
goplsSection = strings.Join(allGoplsOutput, "\n\n")
}
lintSection := formatToolResult(lintRes, "No lint issues.")
// Build file list for the report header.
var fileNames []string
for absPath := range files {
fileNames = append(fileNames, filepath.Base(absPath))
}
report := fmt.Sprintf(
"<go_diagnostics files=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
strings.Join(fileNames, ", "),
goplsSection,
lintSection,
)
goplsIssues, lintIssues := countIssues(report)
hasIssues := goplsIssues > 0 || lintIssues > 0
if hasIssues {
// Show TUI block so the user sees it too.
var msgLines []string
msgLines = append(msgLines, fmt.Sprintf("File: %s", filepath.Base(absPath)))
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
if goplsIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
}
if lintIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
}
msgLines = append(msgLines, "", "⚠️ Please fix these issues before proceeding.")
borderColor := "#f9e2af" // yellow
if goplsIssues > 0 && lintIssues > 0 {
@@ -78,9 +124,16 @@ func Init(api ext.API) {
BorderColor: borderColor,
Subtitle: "go-edit-lint",
})
}
return &ext.ToolResultResult{Content: &enhanced}
// Inject a follow-up message so the agent fixes the issues.
ctx.SendMessage(report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review and fix the issues above.")
} else {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: fmt.Sprintf("Files: %s\n✓ All clean", strings.Join(fileNames, ", ")),
BorderColor: "#a6e3a1",
Subtitle: "go-edit-lint",
})
}
})
}
@@ -106,18 +159,6 @@ func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
return absPath, true
}
func runGoDiagnostics(cwd, absPath string) string {
gopls := runGopls(cwd, absPath)
lint := runGolangCILint(cwd, "./...")
return fmt.Sprintf(
"<go_diagnostics file=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
filepath.Base(absPath),
formatToolResult(gopls, "No diagnostics."),
formatToolResult(lint, "No lint issues."),
)
}
func runGopls(cwd, absPath string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
@@ -178,7 +219,9 @@ func formatToolResult(res lintResult, emptyFallback string) string {
out := strings.TrimSpace(res.Output)
if out == "" {
if res.Err == nil {
lines = append(lines, emptyFallback)
if emptyFallback != "" {
lines = append(lines, emptyFallback)
}
}
} else {
lines = append(lines, out)
@@ -197,17 +240,15 @@ func truncate(s string, max int) string {
}
func countIssues(report string) (goplsCount, lintCount int) {
// Extract gopls section
goplsStart := strings.Index(report, "[gopls]")
lintStart := strings.Index(report, "[golangci-lint]")
endTag := strings.Index(report, "</go_diagnostics>")
if goplsStart != -1 && lintStart != -1 {
goplsSection := report[goplsStart:lintStart]
// Count non-empty lines excluding the header and "No diagnostics." message
for _, line := range strings.Split(goplsSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[gopls]" && line != "No diagnostics." {
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
goplsCount++
}
}
@@ -215,7 +256,6 @@ func countIssues(report string) (goplsCount, lintCount int) {
if lintStart != -1 && endTag != -1 {
lintSection := report[lintStart:endTag]
// Count non-empty lines excluding the header and "No lint issues." message
for _, line := range strings.Split(lintSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
+86
View File
@@ -0,0 +1,86 @@
---
description: Create a feature request using the GitHub template
---
Create a feature request for the Kit repository. The user wants to request: $@
## Feature Request Template
This prompt uses the `feature_request` GitHub template which requires:
| Field | Required | Purpose |
|-------|----------|---------|
| **Feature Description** | Yes | What should be added or changed |
| **Motivation / Use Case** | Yes | Why is this needed? What problem does it solve? |
| **Proposed Implementation** | No | How do you think this should work? |
## Steps
1. **Understand the request** from `$@`
- What capability is missing?
- What would the ideal behavior look like?
2. **Ask clarifying questions** if needed:
- "What problem does this solve for you?"
- "How would you expect this to work?"
- "Are there similar features in other tools you use?"
3. **Craft the title** using conventional format:
- `feat: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Good examples:
- `feat: add keyboard shortcut for clearing input`
- `feat: support custom themes per extension`
- `feat: add fuzzy matching to model selector`
- Bad examples:
- `Feature request: can we have...` (too vague)
- `It would be nice if...` (not imperative)
4. **Build the body** with the template fields:
**Feature Description:**
- Clear statement of what to add/change
- Be specific about the behavior
- Include UI/UX details if relevant
**Motivation / Use Case:**
- What problem does this solve?
- Current workaround (if any) and why it's insufficient
- Who benefits from this feature?
**Proposed Implementation** (optional but helpful):
- High-level approach
- API changes if applicable
- Example usage code
5. **Create the issue**:
```bash
gh issue create --template feature_request --title "feat: ..." --body "..."
```
6. **Confirm success**:
- Show the issue URL and number
- Mention it was created with the feature_request template
## Guidelines
- Focus on the *problem* first, then the solution
- Include concrete examples of how the feature would be used
- Consider edge cases and mention them
- If proposing API changes, show before/after code
- Check if similar features exist in related tools (mention them for reference)
- Align with Kit's philosophy: TUI-first, extension-based, keyboard-driven
## Example
User: `/feature-request I want to be able to customize tool border colors dynamically`
You:
1. Title: `feat: dynamic border colors for tool results based on status`
2. Body:
- **Feature Description**: Allow `ToolRenderConfig` to accept a function that determines border color based on tool result content or status, enabling dynamic visual feedback.
- **Motivation**: When running multiple tools, it's hard to distinguish file reads (blue), shell commands (green), and errors (red) without custom colors per result.
- **Proposed Implementation**: Add `BorderColorFunc` callback that receives `(result string, isError bool)` and returns a color string.
3. Execute: `gh issue create --template feature_request --title "feat: ..." --body "..."`
4. Confirm: Created issue #43 using feature_request template
+100
View File
@@ -0,0 +1,100 @@
---
description: File a GitHub issue using the appropriate template
---
File a GitHub issue for the Kit repository. The user wants to create an issue about: $@
## Issue Templates Available
This repository has structured issue templates. You MUST use the appropriate template:
| Type | Template | Use For |
|------|----------|---------|
| `bug` | `bug_report` | Something is broken, not working as expected |
| `feat` | `feature_request` | New feature, enhancement, improvement |
| `docs` | `documentation` | Missing, incorrect, or unclear documentation |
## Steps
1. **Determine the issue type** from `$@`:
- Bug → use `--template bug_report`
- Feature → use `--template feature_request`
- Documentation → use `--template documentation`
2. **Ask clarifying questions** if critical info is missing:
- For bugs: "What were you doing when this happened?" (reproduction steps)
- For features: "What problem does this solve?" (motivation)
- For docs: "Where did you look for this information?" (location)
3. **Craft the title** using conventional format:
- `<type>: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Examples:
- `fix: ToolRenderConfig BorderColor ignored during rendering`
- `feat: add keyboard shortcut for clearing input`
- `docs: clarify extension widget lifecycle`
4. **File the issue** using the template:
```bash
# For bugs
gh issue create --template bug_report --title "fix: ..." --body "..."
# For features
gh issue create --template feature_request --title "feat: ..." --body "..."
# For documentation
gh issue create --template documentation --title "docs: ..." --body "..."
```
The template will guide the user through the required fields. You need to provide:
- **Bug reports**: Description, reproduction steps, expected vs actual behavior
- **Feature requests**: Description, motivation/use case, optional proposed implementation
- **Documentation**: Description, location of docs, suggested improvement
5. **Confirm success** by showing:
- The issue URL
- The issue number
- Which template was used
## Template Field Guide
### Bug Report (`bug_report`)
Required fields in the body:
- **Bug Description** - what happened vs expected
- **Steps to Reproduce** - numbered list to recreate the bug
- **Relevant Code** - code snippets, configuration, error messages
- **Component** - which part of Kit (ui, extensions, session, etc.)
- **Version** - Kit version or commit hash
### Feature Request (`feature_request`)
Required fields in the body:
- **Feature Description** - what to add/change
- **Motivation / Use Case** - why this is needed
- **Proposed Implementation** - how it could work (optional)
### Documentation (`documentation`)
Required fields in the body:
- **Documentation Issue** - what's wrong or missing
- **Documentation Location** - file or URL where docs exist
- **Suggested Improvement** - how to fix the docs
## Guidelines
- ALWAYS use `--template <name>` instead of bare `gh issue create`
- Include file paths and line numbers when you know them
- Use triple backticks for code blocks
- Keep the body factual - avoid speculation unless in "Proposed Fix" section
- If you're unsure about technical details, say so in the issue
- For UI bugs, describe what you see vs what you expect
- For API bugs, include the relevant struct/function names
## Example Usage
User: `/file-issue The ToolRenderConfig BorderColor field is documented but never used in rendering`
You:
1. Determine this is a **bug** (documented field doesn't work)
2. Use `--template bug_report`
3. Gather: reproduction steps (register renderer with BorderColor), expected (custom color), actual (default color)
4. Create issue with title `fix: ToolRenderConfig BorderColor and Background fields are ignored`
5. Confirm: Created issue #42 using bug_report template
+1 -1
View File
@@ -477,7 +477,7 @@ During an interactive session, use these slash commands:
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message |
| `/new` | Start a fresh session |
## Go SDK
+124 -18
View File
@@ -17,6 +17,7 @@ import (
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/prompts"
"github.com/mark3labs/kit/internal/ui"
"github.com/mark3labs/kit/internal/ui/commands"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -153,6 +154,9 @@ func InitConfig() {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Rebuild the model registry now that viper has the config loaded,
// so customModels defined in the config file are picked up.
models.ReloadGlobalRegistry()
}
// LoadConfigWithEnvSubstitution loads a config file with environment variable
@@ -386,21 +390,21 @@ func runKit(ctx context.Context) error {
}
// extensionCommandsForUI converts extension-registered CommandDefs into the
// ui.ExtensionCommand type used by the interactive TUI. Command names are
// commands.ExtensionCommand type used by the interactive TUI. Command names are
// normalised to start with "/" so they integrate with the slash-command
// autocomplete and dispatch pipeline.
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
func extensionCommandsForUI(k *kit.Kit) []commands.ExtensionCommand {
defs := k.Extensions().Commands()
if len(defs) == 0 {
return nil
}
cmds := make([]ui.ExtensionCommand, 0, len(defs))
cmds := make([]commands.ExtensionCommand, 0, len(defs))
for _, d := range defs {
name := d.Name
if len(name) > 0 && name[0] != '/' {
name = "/" + name
}
ec := ui.ExtensionCommand{
ec := commands.ExtensionCommand{
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
@@ -713,13 +717,20 @@ func runNormalMode(ctx context.Context) error {
// Build Kit options from CLI flags and create the SDK instance.
// kit.New() handles: config → skills → agent → session → extension bridge.
authHandler, authErr := kit.NewCLIMCPAuthHandler()
if authErr != nil {
// Non-fatal: OAuth just won't be available for remote MCP servers.
fmt.Fprintf(os.Stderr, "Warning: Failed to create OAuth handler: %v\n", authErr)
}
kitOpts := &kit.Options{
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
MCPAuthHandler: authHandler,
CLI: &kit.CLIOptions{
MCPConfig: mcpConfig,
ShowSpinner: true,
@@ -792,6 +803,13 @@ func runNormalMode(ctx context.Context) error {
appInstance := app.New(appOpts, messages)
defer appInstance.Close()
// Wire OAuth handler to route messages through the TUI once it's running.
if authHandler != nil {
authHandler.NotifyFunc = func(serverName, message string) {
appInstance.PrintFromExtension("info", message)
}
}
// Buffer for extension messages during startup (printed after startup banner).
var startupExtensionMessages []string
@@ -815,7 +833,37 @@ func runNormalMode(ctx context.Context) error {
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
Abort: func() { appInstance.Abort() },
IsIdle: func() bool { return !appInstance.IsBusy() },
Compact: func(cfg extensions.CompactConfig) error {
return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError)
},
SendMultimodalMessage: func(text string, files []extensions.FilePart) {
parts := make([]kit.LLMFilePart, len(files))
for i, f := range files {
parts[i] = kit.LLMFilePart{
Filename: f.Filename,
Data: f.Data,
MediaType: f.MediaType,
}
}
appInstance.RunWithFiles(text, parts)
},
GetSessionUsage: func() extensions.SessionUsage {
if usageTracker == nil {
return extensions.SessionUsage{}
}
stats := usageTracker.GetSessionStats()
return extensions.SessionUsage{
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheReadTokens: stats.TotalCacheReadTokens,
TotalCacheWriteTokens: stats.TotalCacheWriteTokens,
TotalCost: stats.TotalCost,
RequestCount: stats.RequestCount,
}
},
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
@@ -1236,7 +1284,37 @@ func runNormalMode(ctx context.Context) error {
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
Abort: func() { appInstance.Abort() },
IsIdle: func() bool { return !appInstance.IsBusy() },
Compact: func(cfg extensions.CompactConfig) error {
return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError)
},
SendMultimodalMessage: func(text string, files []extensions.FilePart) {
parts := make([]kit.LLMFilePart, len(files))
for i, f := range files {
parts[i] = kit.LLMFilePart{
Filename: f.Filename,
Data: f.Data,
MediaType: f.MediaType,
}
}
appInstance.RunWithFiles(text, parts)
},
GetSessionUsage: func() extensions.SessionUsage {
if usageTracker == nil {
return extensions.SessionUsage{}
}
stats := usageTracker.GetSessionStats()
return extensions.SessionUsage{
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheReadTokens: stats.TotalCacheReadTokens,
TotalCacheWriteTokens: stats.TotalCacheWriteTokens,
TotalCost: stats.TotalCost,
RequestCount: stats.RequestCount,
}
},
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
@@ -1547,7 +1625,7 @@ func runNormalMode(ctx context.Context) error {
emitBeforeFork := beforeForkProviderForUI(kitInstance)
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
getExtensionCommands := func() []ui.ExtensionCommand {
getExtensionCommands := func() []commands.ExtensionCommand {
return extensionCommandsForUI(kitInstance)
}
@@ -1604,9 +1682,36 @@ func runNormalMode(ctx context.Context) error {
return nil
}
// Build extension reload callback for the /reload-ext command.
reloadExtensionsForUI := func() error {
err := kitInstance.Extensions().Reload()
if err != nil {
return err
}
go appInstance.NotifyWidgetUpdate()
return nil
}
// Start file watcher for automatic extension hot-reload.
extraPaths := viper.GetStringSlice("extension")
watchDirs := extensions.WatchedDirs(extraPaths)
if len(watchDirs) > 0 {
extWatcher, watchErr := extensions.NewWatcher(watchDirs, func() {
if err := reloadExtensionsForUI(); err != nil {
log.Printf("auto-reload extensions failed: %v", err)
}
})
if watchErr != nil {
log.Printf("extension file watcher not started: %v", watchErr)
} else {
go extWatcher.Start(ctx)
defer func() { _ = extWatcher.Close() }()
}
}
// Check if running in non-interactive mode
if positionalPrompt != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
}
// Quiet mode is not allowed in interactive mode
@@ -1614,7 +1719,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, startupExtensionMessages)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1627,7 +1732,7 @@ func runNormalMode(ctx context.Context) error {
//
// When --no-exit is set, after the prompt completes the interactive BubbleTea
// TUI is started so the user can continue the conversation.
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
// Expand @file references in the prompt before sending to the agent.
if cwd, err := os.Getwd(); err == nil {
prompt = ui.ProcessFileAttachments(prompt, cwd)
@@ -1670,7 +1775,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, nil)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
}
return nil
@@ -1768,7 +1873,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1812,6 +1917,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ReloadExtensions: reloadExtensions,
ShowSessionPicker: resumeFlag,
})
+6 -4
View File
@@ -7,10 +7,12 @@
// development: edit your extension source, then type /reload to pick up
// changes immediately.
//
// Event handlers, slash commands, tool renderers, message renderers, and
// keyboard shortcuts update immediately. Extension-defined tools are NOT
// updated (they are baked into the agent at creation time and require a
// restart).
// Note: Extensions in autoloaded directories (~/.config/kit/extensions/
// and .kit/extensions/) are automatically reloaded on save. The /reload
// command is useful for extensions loaded via -e from other locations.
//
// Event handlers, slash commands, tool definitions, tool renderers,
// message renderers, and keyboard shortcuts all update immediately.
//
// Commands:
// /reload — hot-reload all extensions from disk
+74 -1
View File
@@ -168,6 +168,10 @@ var (
// Test
pendingTest *PendingTest
// Typing indicator
typingTicker *time.Ticker
typingStop chan struct{}
// Latest context for background goroutines
latestCtx ext.Context
latestCtxSet bool
@@ -203,8 +207,23 @@ func configDir() string {
return filepath.Join(home, ".config", "kit")
}
func globalConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "kit")
}
func configPath() string {
return filepath.Join(configDir(), "kit-telegram.json")
// Prefer project-local config, fall back to global config.
local := filepath.Join(configDir(), "kit-telegram.json")
if _, err := os.Stat(local); err == nil {
return local
}
global := filepath.Join(globalConfigDir(), "kit-telegram.json")
if _, err := os.Stat(global); err == nil {
return global
}
// Neither exists — return local path (will be created on connect).
return local
}
func failureLogDir() string {
@@ -387,6 +406,14 @@ func tgEditMessageText(token string, chatID int64, messageID int, text string) (
return &msg, nil
}
func tgSendChatAction(token string, chatID int64, action string) error {
_, err := telegramRequest(token, "sendChatAction", map[string]any{
"chat_id": chatID,
"action": action,
}, 15)
return err
}
// ──────────────────────────────────────────────
// Error classification
// ──────────────────────────────────────────────
@@ -637,6 +664,48 @@ func clearHealthTimer() {
}
}
// ──────────────────────────────────────────────
// Typing indicator
// ──────────────────────────────────────────────
func startTypingLoop() {
mu.Lock()
defer mu.Unlock()
if typingTicker != nil {
return
}
cfg := config
if cfg == nil || !cfg.Enabled {
return
}
token := cfg.BotToken
chatID := cfg.ChatID
typingTicker = time.NewTicker(4 * time.Second)
typingStop = make(chan struct{})
// Send immediately, then every 4 seconds.
go func() {
tgSendChatAction(token, chatID, "typing")
for {
select {
case <-typingTicker.C:
tgSendChatAction(token, chatID, "typing")
case <-typingStop:
return
}
}
}()
}
func stopTypingLoop() {
mu.Lock()
defer mu.Unlock()
if typingTicker != nil {
typingTicker.Stop()
close(typingStop)
typingTicker = nil
}
}
// ──────────────────────────────────────────────
// Polling lifecycle
// ──────────────────────────────────────────────
@@ -2105,6 +2174,7 @@ func Init(api ext.API) {
mu.Unlock()
sendShutdownDisconnectedMessage()
stopTypingLoop()
stopPolling()
clearHealthTimer()
clearFooter()
@@ -2128,6 +2198,7 @@ func Init(api ext.API) {
mu.Unlock()
report("run.start", fmt.Sprintf("runId=%d", run.ID))
startTypingLoop()
ensureProgressMessage()
updateProgressMessage()
})
@@ -2140,6 +2211,8 @@ func Init(api ext.API) {
run := activeRun
mu.Unlock()
stopTypingLoop()
if run != nil {
// Capture final response from event
if e.Response != "" {
+8 -8
View File
@@ -9,12 +9,14 @@ require (
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
github.com/alecthomas/chroma/v2 v2.23.1
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/coder/acp-go-sdk v0.6.3
github.com/indaco/herald v0.10.0
github.com/indaco/herald-md v0.1.0
github.com/indaco/herald v0.11.0
github.com/indaco/herald-md v0.2.0
github.com/mark3labs/mcp-go v0.46.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
@@ -30,7 +32,6 @@ require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
@@ -52,7 +53,6 @@ require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 // indirect
@@ -115,10 +115,10 @@ require (
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.273.0 // indirect
google.golang.org/genai v1.52.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/api v0.273.1 // indirect
google.golang.org/genai v1.52.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+14 -14
View File
@@ -181,10 +181,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/indaco/herald v0.10.0 h1:XzahEKX6cr50qZQrUdA3QrQBHg8uGm5jETD0UDi21BI=
github.com/indaco/herald v0.10.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.1.0 h1:zmYudYo+uamzKTBcIffJVJYrqk9xDNnVrTh+de2zciw=
github.com/indaco/herald-md v0.1.0/go.mod h1:Z1HxPCbSn+/+TFzOM/UbsmKeEk/28NNI6JOTileKXto=
github.com/indaco/herald v0.11.0 h1:tJZc6DAzfUYVWQsU9Lik4RcKR7TtiRfnBIu/oXjp/WA=
github.com/indaco/herald v0.11.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.2.0 h1:kGFsKE+Swzf7EyTUFx7FL1d1jwiKoJRcxqYo2bhUgS0=
github.com/indaco/herald-md v0.2.0/go.mod h1:64DKh1wSQUsWXTuIYklFzSheJKkW0+FpaqyKqwids3g=
github.com/kaptinlin/go-i18n v0.3.0 h1:wP76dvYg04bvwTb+8NB+CmdZ2kL7lSSCQ9B/kFv7QHo=
github.com/kaptinlin/go-i18n v0.3.0/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
@@ -307,20 +307,20 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genai v1.52.0 h1:ekVIxWHtLUNbt+v0WWi4j3JT4yrHDEbysMcHQcaCQoI=
google.golang.org/genai v1.52.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.273.1 h1:L7G/TmpAMz0nKx/ciAVssVmWQiOF6+pOuXeKrWVsquY=
google.golang.org/api v0.273.1/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+66 -4
View File
@@ -25,6 +25,11 @@ type AgentConfig struct {
StreamingEnabled bool
DebugLogger tools.DebugLogger
// AuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports are configured with OAuth support.
// If nil, remote MCP servers that require OAuth will fail to connect.
AuthHandler tools.MCPAuthHandler
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used. This allows SDK users to provide a custom tool set (e.g.
// CodingTools or tools with a custom WorkDir).
@@ -63,6 +68,10 @@ type ToolCallContentHandler func(content string)
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
type ReasoningDeltaHandler func(delta string)
// ReasoningCompleteHandler is a function type for handling reasoning/thinking completion.
// Called when the last reasoning token has been processed, before text streaming starts.
type ReasoningCompleteHandler func()
// ToolOutputHandler is a function type for handling streaming tool output chunks.
// Used by tools like bash to stream output as it arrives rather than waiting
// for the command to complete. The isStderr flag indicates if the chunk
@@ -135,6 +144,10 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
toolManager = tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
if agentConfig.AuthHandler != nil {
toolManager.SetAuthHandler(agentConfig.AuthHandler)
}
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
@@ -231,7 +244,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, nil, nil, nil)
onResponse, onToolCallContent, nil, nil, nil, nil, nil)
}
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
@@ -242,6 +255,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler,
onReasoningDelta ReasoningDeltaHandler,
onReasoningComplete ReasoningCompleteHandler,
onToolOutput ToolOutputHandler,
onStepUsage StepUsageHandler,
) (*GenerateWithLoopResult, error) {
@@ -295,6 +309,17 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
return nil
},
// Reasoning/thinking complete callback
OnReasoningEnd: func(id string, _ fantasy.ReasoningContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningComplete != nil {
onReasoningComplete()
}
return nil
},
// Text streaming callback
OnTextDelta: func(id, text string) error {
if ctx.Err() != nil {
@@ -381,7 +406,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
opts fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
// Drain all pending steer messages (non-blocking).
var steered []string
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
@@ -398,9 +423,9 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if len(steered) > 0 {
// Inject each steer message as a user message so the
// LLM sees the redirection on the next step.
for _, text := range steered {
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(text))
fantasy.NewUserMessage(sm.Text, sm.Files...))
}
// Notify that steer messages were consumed.
if onConsumed != nil {
@@ -623,6 +648,43 @@ func (a *Agent) GetExtensionToolCount() int {
return len(a.extraTools)
}
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
// tools) and rebuilds the internal agent with the updated tool list. The
// model, system prompt, and all other configuration are preserved.
func (a *Agent) SetExtraTools(tools []fantasy.AgentTool) {
a.extraTools = tools
// Rebuild tool list (same as NewAgent / SetModel).
allTools := make([]fantasy.AgentTool, len(a.coreTools))
copy(allTools, a.coreTools)
if a.toolManager != nil {
allTools = append(allTools, a.toolManager.GetTools()...)
}
if len(a.extraTools) > 0 {
allTools = append(allTools, a.extraTools...)
}
if a.toolWrapper != nil {
allTools = a.toolWrapper(allTools)
}
// Rebuild agent options with the existing model.
var agentOpts []fantasy.AgentOption
if a.systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
}
if len(allTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
}
if a.maxSteps > 0 {
agentOpts = append(agentOpts, fantasy.WithStopConditions(
fantasy.StepCountIs(a.maxSteps),
))
}
// Swap the fantasy agent (model and provider are unchanged).
a.fantasyAgent = fantasy.NewAgent(a.model, agentOpts...)
}
// GetLoadingMessage returns the loading message from provider creation.
func (a *Agent) GetLoadingMessage() string {
return a.loadingMessage
+3
View File
@@ -36,6 +36,8 @@ type AgentCreationOptions struct {
SpinnerFunc SpinnerFunc // Function to show spinner (provided by caller)
// DebugLogger is an optional logger for debugging MCP communications
DebugLogger tools.DebugLogger // Optional debug logger
// AuthHandler handles OAuth authorization for remote MCP servers
AuthHandler tools.MCPAuthHandler
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used.
CoreTools []fantasy.AgentTool
@@ -56,6 +58,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
MaxSteps: opts.MaxSteps,
StreamingEnabled: opts.StreamingEnabled,
DebugLogger: opts.DebugLogger,
AuthHandler: opts.AuthHandler,
CoreTools: opts.CoreTools,
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
+15 -4
View File
@@ -1,6 +1,17 @@
package agent
import "context"
import (
"context"
"charm.land/fantasy"
)
// SteerMessage carries a steering prompt and optional file attachments
// (e.g. clipboard images) through the steer channel.
type SteerMessage struct {
Text string
Files []fantasy.FilePart
}
// steerChKey is the context key for the steer channel.
type steerChKey struct{}
@@ -11,7 +22,7 @@ type steerConsumedKey struct{}
// ContextWithSteerCh returns a new context with the steer channel attached.
// The agent's PrepareStep function checks this channel between steps and
// injects any pending steer messages as user messages before the next LLM call.
func ContextWithSteerCh(ctx context.Context, ch <-chan string) context.Context {
func ContextWithSteerCh(ctx context.Context, ch <-chan SteerMessage) context.Context {
return context.WithValue(ctx, steerChKey{}, ch)
}
@@ -23,8 +34,8 @@ func ContextWithSteerConsumed(ctx context.Context, fn func(count int)) context.C
}
// steerChFromContext extracts the steer channel from the context, or nil.
func steerChFromContext(ctx context.Context) <-chan string {
ch, _ := ctx.Value(steerChKey{}).(<-chan string)
func steerChFromContext(ctx context.Context) <-chan SteerMessage {
ch, _ := ctx.Value(steerChKey{}).(<-chan SteerMessage)
return ch
}
+106 -5
View File
@@ -162,6 +162,24 @@ func (a *App) CancelCurrentStep() {
cancel()
}
// IsBusy returns true when the agent is currently processing a turn.
func (a *App) IsBusy() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.busy
}
// Abort cancels the current agent step (if running) and clears the queue.
// Unlike InterruptAndSend, no new message is injected — the agent simply
// stops. Safe to call when idle (no-op).
func (a *App) Abort() {
a.mu.Lock()
a.queue = a.queue[:0]
cancel := a.cancelStep
a.mu.Unlock()
cancel()
}
// QueueLength returns the number of prompts currently waiting in the queue.
//
// Satisfies ui.AppController.
@@ -187,6 +205,15 @@ func (a *App) QueueLength() int {
//
// Satisfies ui.AppController.
func (a *App) Steer(prompt string) int {
return a.SteerWithFiles(prompt, nil)
}
// SteerWithFiles injects a steering message with optional file attachments
// (e.g. pasted images) into the currently running agent turn. Behaves like
// Steer but includes file parts alongside the text.
//
// Satisfies ui.AppController.
func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
@@ -195,8 +222,8 @@ func (a *App) Steer(prompt string) int {
}
if !a.busy {
// Not busy — start immediately, same as Run().
item := queueItem{Prompt: prompt}
// Not busy — start immediately, same as RunWithFiles().
item := queueItem{Prompt: prompt, Files: files}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
@@ -211,7 +238,7 @@ func (a *App) Steer(prompt string) int {
// execution, before next LLM call). If PrepareStep doesn't fire
// (text-only response), drainQueue will pick it up after the turn.
if a.opts.Kit != nil {
a.opts.Kit.InjectSteer(prompt)
a.opts.Kit.InjectSteerWithFiles(prompt, files)
}
return 1
}
@@ -390,6 +417,78 @@ func (a *App) CompactConversation(customInstructions string) error {
return nil
}
// CompactAsync is like CompactConversation but calls onComplete/onError
// callbacks instead of sending TUI events. Used by the extension API's
// ctx.Compact() which needs callback-based notification.
func (a *App) CompactAsync(customInstructions string, onComplete func(), onError func(string)) error {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return fmt.Errorf("app is closed")
}
if a.busy {
a.mu.Unlock()
return fmt.Errorf("cannot compact while the agent is working")
}
if a.opts.Kit == nil {
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go func() {
defer a.wg.Done()
defer func() {
a.mu.Lock()
a.busy = false
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})
if onError != nil {
onError(err.Error())
}
return
}
if result == nil {
a.sendEvent(CompactErrorEvent{Err: fmt.Errorf("nothing to compact")})
if onError != nil {
onError("nothing to compact")
}
return
}
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
a.sendEvent(CompactCompleteEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
})
if onComplete != nil {
onComplete()
}
}()
return nil
}
// --------------------------------------------------------------------------
// Non-interactive execution
// --------------------------------------------------------------------------
@@ -530,8 +629,8 @@ func (a *App) drainQueue(first queueItem) {
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
a.mu.Lock()
steerItems := make([]queueItem, len(leftover))
for i, text := range leftover {
steerItems[i] = queueItem{Prompt: text}
for i, sm := range leftover {
steerItems[i] = queueItem{Prompt: sm.Text, Files: sm.Files}
}
a.queue = append(steerItems, a.queue...)
a.mu.Unlock()
@@ -788,6 +887,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
sendFn(StreamChunkEvent{Content: ev.Chunk})
case kit.ReasoningDeltaEvent:
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
case kit.ReasoningCompleteEvent:
sendFn(ReasoningCompleteEvent{})
case kit.ToolOutputEvent:
sendFn(ToolOutputEvent{
ToolCallID: ev.ToolCallID,
+5
View File
@@ -16,6 +16,11 @@ type ReasoningChunkEvent struct {
Delta string
}
// ReasoningCompleteEvent is sent when reasoning/thinking is finished, after
// the last reasoning token has been processed. The TUI uses this to freeze
// the reasoning duration counter.
type ReasoningCompleteEvent struct{}
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
// It carries the tool name and its arguments for display purposes.
type ToolCallStartedEvent struct {
+2
View File
@@ -162,6 +162,8 @@ type Theme struct {
// and merged into the custom provider in the model registry.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
+1 -20
View File
@@ -67,7 +67,7 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
if info.IsDir() {
return readDirectory(absPath)
return fantasy.NewTextErrorResponse(fmt.Sprintf("'%s' is a directory, not a file. Use the ls tool to list directory contents.", args.Path)), nil
}
content, err := os.ReadFile(absPath)
@@ -116,25 +116,6 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
return fantasy.NewTextResponse(tr.Content), nil
}
func readDirectory(absPath string) (fantasy.ToolResponse, error) {
entries, err := os.ReadDir(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
}
var result strings.Builder
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
name += "/"
}
result.WriteString(name + "\n")
}
tr := truncateHead(result.String(), 500, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// resolvePathWithWorkDir resolves a path to an absolute path relative to the
// given workDir. If workDir is empty, os.Getwd() is used.
func resolvePathWithWorkDir(path, workDir string) (string, error) {
+26 -33
View File
@@ -130,13 +130,22 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
), fmt.Errorf("no subagent spawner in context")
}
// Detach from the parent's deadline so the subagent gets its own
// independent timeout (applied downstream in Kit.Subagent). The parent
// context may carry a tight deadline from the LLM generation loop or
// other tool timeouts that would prematurely kill the subagent.
// We preserve context values (spawner, etc.) and propagate parent
// cancellation (e.g. user hits Ctrl-C) without inheriting the deadline.
spawnCtx := detachedWithCancel(ctx)
// Build a clean context for the subagent that inherits values (e.g. the
// spawner callback) but is completely detached from the parent's
// deadline AND cancellation. The subagent gets its own independent
// timeout (applied downstream in Kit.Subagent).
//
// Why full detachment instead of propagating parent cancellation?
// The parent context may already be done (deadline exceeded or
// cancelled) by the time this tool handler executes — for example when
// the generation loop context carries a deadline, when the user
// double-ESC cancels mid-turn, or when parallel tool execution
// encounters a race between stream completion and tool dispatch. Using
// context.WithoutCancel (Go 1.21+) ensures the subagent always starts
// cleanly with a fresh timeout, following the pattern used by crush for
// shutdown-resilient child work. The subagent's own timeout
// (defaultSubagentTimeout / user-specified) provides the safety net.
spawnCtx := context.WithoutCancel(valuesContext{parent: ctx})
// Spawn in-process subagent.
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
@@ -173,37 +182,21 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
}
// ---------------------------------------------------------------------------
// Context detachment
// Context helpers
// ---------------------------------------------------------------------------
// detachedContext wraps a parent context, preserving its values but removing
// its deadline and cancellation. This allows the subagent to have its own
// independent timeout while still accessing context-stored values (e.g. the
// subagent spawner function).
type detachedContext struct {
// valuesContext preserves a parent context's values (e.g. the subagent
// spawner callback) while stripping its deadline and cancellation. Combined
// with context.WithoutCancel() this gives the subagent a completely clean
// context that only inherits value-based dependencies.
type valuesContext struct {
parent context.Context
}
func (d detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (d detachedContext) Done() <-chan struct{} { return nil }
func (d detachedContext) Err() error { return nil }
func (d detachedContext) Value(key any) any { return d.parent.Value(key) }
// detachedWithCancel creates a new context that inherits values from the
// parent but has no deadline. Cancellation of the parent is propagated: when
// the parent is cancelled the returned context is also cancelled, but the
// parent's deadline does not apply to the child.
func detachedWithCancel(parent context.Context) context.Context {
child, cancel := context.WithCancel(detachedContext{parent: parent})
go func() {
select {
case <-parent.Done():
cancel()
case <-child.Done():
}
}()
return child
}
func (v valuesContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (v valuesContext) Done() <-chan struct{} { return nil }
func (v valuesContext) Err() error { return nil }
func (v valuesContext) Value(key any) any { return v.parent.Value(key) }
// truncateResponse limits the response length to avoid overwhelming context windows.
func truncateResponse(s string, maxLen int) string {
+115
View File
@@ -0,0 +1,115 @@
package core
import (
"context"
"testing"
"time"
)
func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) {
// Parent with a tight deadline.
parent, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond) // Let deadline expire.
if parent.Err() == nil {
t.Fatal("expected parent to be expired")
}
vc := valuesContext{parent: parent}
if _, ok := vc.Deadline(); ok {
t.Error("valuesContext should report no deadline")
}
if vc.Done() != nil {
t.Error("valuesContext.Done() should return nil")
}
if vc.Err() != nil {
t.Errorf("valuesContext.Err() should be nil, got %v", vc.Err())
}
}
func TestValuesContext_PreservesValues(t *testing.T) {
type testKey struct{}
parent := context.WithValue(context.Background(), testKey{}, "hello")
vc := valuesContext{parent: parent}
got, ok := vc.Value(testKey{}).(string)
if !ok || got != "hello" {
t.Errorf("expected value 'hello', got %q (ok=%v)", got, ok)
}
}
func TestSpawnContext_SurvivesCancelledParent(t *testing.T) {
// Simulate the exact scenario from the bug: the parent generation
// context is already cancelled when the subagent tool handler runs.
parent, cancel := context.WithCancel(context.Background())
cancel() // Cancelled before detach.
// This is what executeSubagent now does:
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
// The spawn context must be alive.
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive, got err: %v", spawnCtx.Err())
}
// Adding a timeout should produce a working context.
tCtx, tCancel := context.WithTimeout(spawnCtx, 5*time.Second)
defer tCancel()
if tCtx.Err() != nil {
t.Fatalf("timeout context should be alive, got err: %v", tCtx.Err())
}
}
func TestSpawnContext_SurvivesDeadlineExceededParent(t *testing.T) {
// Simulate: parent had a deadline that already expired.
parent, pCancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer pCancel()
time.Sleep(5 * time.Millisecond)
if parent.Err() != context.DeadlineExceeded {
t.Fatalf("expected parent deadline exceeded, got: %v", parent.Err())
}
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive after deadline-exceeded parent, got: %v", spawnCtx.Err())
}
}
func TestSpawnContext_PreservesSpawnerValue(t *testing.T) {
// Verify the subagent spawner callback survives context detachment.
called := false
spawner := SubagentSpawnFunc(func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) {
called = true
return &SubagentSpawnResult{Response: "ok"}, nil
})
parent := WithSubagentSpawner(context.Background(), spawner)
// Cancel the parent.
parentCtx, cancel := context.WithCancel(parent)
cancel()
spawnCtx := context.WithoutCancel(valuesContext{parent: parentCtx})
// Should be able to retrieve the spawner from the detached context.
recovered := getSubagentSpawner(spawnCtx)
if recovered == nil {
t.Fatal("spawner should be recoverable from detached context")
}
result, err := recovered(spawnCtx, "tc1", "test task", "", "", time.Minute)
if err != nil {
t.Fatalf("spawner call failed: %v", err)
}
if !called {
t.Error("spawner was not called")
}
if result.Response != "ok" {
t.Errorf("expected 'ok', got %q", result.Response)
}
}
+100
View File
@@ -77,6 +77,64 @@ type Context struct {
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// Abort cancels the current agent turn (if running) and clears the
// message queue. Unlike CancelAndSend, no new message is injected —
// the agent simply stops. Safe to call when idle (no-op).
//
// Example:
//
// ctx.Abort() // stop whatever the agent is doing
Abort func()
// IsIdle returns true when the agent is not processing a turn.
// Extensions can use this to decide whether to dispatch immediately
// or queue work for later.
//
// Example:
//
// if ctx.IsIdle() {
// ctx.SendMessage("start new task")
// }
IsIdle func() bool
// Compact triggers context compaction, summarising older messages to
// free context window space. Returns an error if compaction cannot
// start (e.g. agent is busy or app is closed). The actual compaction
// runs asynchronously; use OnComplete/OnError callbacks in
// CompactConfig to observe the result.
//
// Example:
//
// err := ctx.Compact(ext.CompactConfig{
// OnComplete: func() { ctx.PrintInfo("Compaction done") },
// OnError: func(errMsg string) { ctx.PrintError("Compact failed: " + errMsg) },
// })
Compact func(CompactConfig) error
// SendMultimodalMessage injects a message with file attachments (images,
// documents) into the conversation and triggers a new agent turn. Files
// are described by FilePart structs containing the raw bytes, filename,
// and MIME type. If the agent is busy the message is queued.
//
// Example:
//
// data, _ := os.ReadFile("photo.jpg")
// ctx.SendMultimodalMessage("Describe this image", []ext.FilePart{
// {Filename: "photo.jpg", Data: data, MediaType: "image/jpeg"},
// })
SendMultimodalMessage func(text string, files []FilePart)
// GetSessionUsage returns aggregated token usage and cost statistics
// for the current session. This includes total input/output tokens,
// cache read/write tokens, total cost, and request count.
//
// Example:
//
// usage := ctx.GetSessionUsage()
// fmt.Sprintf("Tokens: ↑%d ↓%d Cost: $%.3f",
// usage.TotalInputTokens, usage.TotalOutputTokens, usage.TotalCost)
GetSessionUsage func() SessionUsage
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -937,6 +995,48 @@ type StatusBarEntry struct {
Priority int
}
// CompactConfig configures a programmatic context compaction request.
type CompactConfig struct {
// CustomInstructions is optional text appended to the summary prompt
// (e.g. "Focus on the API design decisions"). Empty uses the default.
CustomInstructions string
// OnComplete is called when compaction finishes successfully.
// May be nil if the caller doesn't need notification.
OnComplete func()
// OnError is called when compaction fails. The argument is the error message.
// May be nil if the caller doesn't need notification.
OnError func(errMsg string)
}
// FilePart describes a file attachment for multimodal messages. Extensions
// use this with SendMultimodalMessage to attach images or documents.
type FilePart struct {
// Filename is the name of the file (e.g. "photo.jpg").
Filename string
// Data is the raw file content.
Data []byte
// MediaType is the MIME type (e.g. "image/jpeg", "application/pdf").
MediaType string
}
// SessionUsage contains aggregated token usage and cost statistics for
// the current session. Extensions use this with GetSessionUsage() to
// report usage information.
type SessionUsage struct {
// TotalInputTokens is the sum of input tokens across all requests.
TotalInputTokens int
// TotalOutputTokens is the sum of output tokens across all requests.
TotalOutputTokens int
// TotalCacheReadTokens is the sum of cache read tokens.
TotalCacheReadTokens int
// TotalCacheWriteTokens is the sum of cache write tokens.
TotalCacheWriteTokens int
// TotalCost is the total cost in USD across all requests.
TotalCost float64
// RequestCount is the number of LLM requests made in this session.
RequestCount int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
type PrintBlockOpts struct {
// Text is the main content to display.
+25 -26
View File
@@ -154,6 +154,11 @@ func NewInstaller(projectDir string) *Installer {
// Install clones a git repository to the appropriate scope.
func (i *Installer) Install(source *GitSource, scope InstallScope) error {
return i.install(source, scope, nil)
}
// install is the internal implementation that supports optional include paths.
func (i *Installer) install(source *GitSource, scope InstallScope, includePaths []string) error {
targetDir := i.getInstallPath(source, scope)
// Check if already installed
@@ -199,6 +204,7 @@ func (i *Installer) Install(source *GitSource, scope InstallScope) error {
Pinned: source.Pinned,
Scope: scope,
Installed: time.Now(),
Include: includePaths,
}
if err := i.addToManifest(entry, scope); err != nil {
// Don't fail the install, just log the error
@@ -268,7 +274,22 @@ func (i *Installer) Update(source *GitSource, scope InstallScope) error {
cleanCmd.Dir = targetDir
_ = cleanCmd.Run() // Ignore errors - clean is best effort
// Update manifest timestamp
// Update manifest timestamp, preserving existing fields like Include
existing, _ := i.loadManifest(scope)
var include []string
var installed time.Time
if existing != nil {
for _, p := range existing.Packages {
if p.Host+"/"+p.Path == source.Identity() {
include = p.Include
installed = p.Installed
break
}
}
}
if installed.IsZero() {
installed = time.Now()
}
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
@@ -277,8 +298,9 @@ func (i *Installer) Update(source *GitSource, scope InstallScope) error {
Ref: "",
Pinned: false,
Scope: scope,
Installed: time.Now(),
Installed: installed,
Updated: time.Now(),
Include: include,
}
_ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails
@@ -503,30 +525,7 @@ func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, st
// InstallWithInclude clones a repo and installs only the specified extensions.
// includePaths are relative paths like "./git/main.go" - if empty, installs all.
func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error {
// First, do a regular install
if err := i.Install(source, scope); err != nil {
return err
}
// If specific includes were requested, update the manifest
if len(includePaths) > 0 {
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Include: includePaths,
}
if err := addEntryToManifest(entry, scope); err != nil {
return fmt.Errorf("updating manifest with includes: %w", err)
}
}
return nil
return i.install(source, scope, includePaths)
}
// CleanupTempDir removes a temporary directory used for preview.
+8 -11
View File
@@ -133,7 +133,7 @@ func findExtensionsInDir(dir string) []string {
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
results = append(results, full)
} else if entry.IsDir() {
main := filepath.Join(full, "main.go")
@@ -190,9 +190,13 @@ func findExtensionsInRepo(repoPath string) []string {
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
if !isExtDir && !isExamplesSubdir {
if !isExtDir {
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if relPath == base { // Top-level directory
@@ -202,13 +206,6 @@ func findExtensionsInRepo(repoPath string) []string {
}
return filepath.SkipDir
}
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
}
return filepath.SkipDir
}
@@ -227,7 +224,7 @@ func findExtensionsInRepo(repoPath string) []string {
}
// It's a file
if !strings.HasSuffix(info.Name(), ".go") {
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
+7 -16
View File
@@ -253,10 +253,13 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Or check if it's a subdirectory of examples/ that might contain extensions
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
if !isExtDir && !isExamplesSubdir {
if !isExtDir {
// Check for main.go before skipping
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
@@ -272,18 +275,6 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
}
return filepath.SkipDir
}
// Inside a valid extensions directory
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
}
// Not an extension location
@@ -309,7 +300,7 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
}
// It's a file - check if it's a valid extension
if !strings.HasSuffix(info.Name(), ".go") {
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
+15
View File
@@ -86,6 +86,21 @@ func normalizeContext(ctx Context) Context {
if ctx.CancelAndSend == nil {
ctx.CancelAndSend = func(string) {}
}
if ctx.Abort == nil {
ctx.Abort = func() {}
}
if ctx.IsIdle == nil {
ctx.IsIdle = func() bool { return true }
}
if ctx.Compact == nil {
ctx.Compact = func(CompactConfig) error { return fmt.Errorf("compact not available") }
}
if ctx.SendMultimodalMessage == nil {
ctx.SendMultimodalMessage = func(string, []FilePart) {}
}
if ctx.GetSessionUsage == nil {
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
}
if ctx.SetWidget == nil {
ctx.SetWidget = func(WidgetConfig) {}
}
+3
View File
@@ -31,6 +31,7 @@ func Symbols() interp.Exports {
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
"SessionUsage": reflect.ValueOf((*SessionUsage)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
@@ -44,6 +45,8 @@ func Symbols() interp.Exports {
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
"CompactConfig": reflect.ValueOf((*CompactConfig)(nil)),
"FilePart": reflect.ValueOf((*FilePart)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
+192
View File
@@ -0,0 +1,192 @@
package extensions
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/fsnotify/fsnotify"
)
// Watcher monitors extension directories for file changes and triggers
// a reload callback when .go files are created, modified, or removed.
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
type Watcher struct {
watcher *fsnotify.Watcher
onReload func()
debounce time.Duration
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
}
// NewWatcher creates a file watcher that monitors the given directories
// for .go file changes. When a change is detected (after debouncing),
// onReload is called. The watcher must be started with Start() and
// stopped with Close().
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating file watcher: %w", err)
}
for _, dir := range dirs {
// Watch the directory itself.
if err := fsw.Add(dir); err != nil {
log.Debug("watcher: skipping directory", "dir", dir, "err", err)
continue
}
// Also watch immediate subdirectories (for */main.go pattern).
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
if err := fsw.Add(subdir); err != nil {
log.Debug("watcher: skipping subdirectory", "dir", subdir, "err", err)
}
}
}
}
return &Watcher{
watcher: fsw,
onReload: onReload,
debounce: 300 * time.Millisecond,
done: make(chan struct{}),
}, nil
}
// Start begins watching for file changes. It blocks until the context
// is cancelled or Close() is called. Typically called in a goroutine.
func (w *Watcher) Start(ctx context.Context) {
w.mu.Lock()
ctx, w.cancel = context.WithCancel(ctx)
w.mu.Unlock()
defer close(w.done)
var timer *time.Timer
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only care about .go files.
if !strings.HasSuffix(event.Name, ".go") {
continue
}
// React to write, create, remove, rename events.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
continue
}
log.Debug("watcher: file changed", "file", event.Name, "op", event.Op)
// Debounce: reset timer on each event.
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
case <-timerC:
timerC = nil
timer = nil
log.Debug("watcher: reloading extensions")
w.onReload()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Warn("watcher: error", "err", err)
}
}
}
// Close stops the watcher and releases resources.
func (w *Watcher) Close() error {
w.mu.Lock()
cancel := w.cancel
w.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait for the event loop to finish.
<-w.done
return w.watcher.Close()
}
// WatchedDirs returns the directories to watch for extension changes.
// This includes the global extensions directory and the project-local
// .kit/extensions/ directory (if they exist). Explicit -e paths that
// point to directories are also included; explicit file paths cause
// their parent directory to be watched instead.
func WatchedDirs(extraPaths []string) []string {
var dirs []string
seen := make(map[string]bool)
add := func(dir string) {
abs, err := filepath.Abs(dir)
if err != nil {
return
}
if seen[abs] {
return
}
// Verify the directory exists.
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return
}
seen[abs] = true
dirs = append(dirs, abs)
}
// Global extensions dir.
add(globalExtensionsDir())
// Project-local extensions dir.
add(filepath.Join(".kit", "extensions"))
// Explicit paths that are directories.
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
}
}
return dirs
}
+158
View File
@@ -0,0 +1,158 @@
package extensions
import (
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatcher_ReloadsOnGoFileChange(t *testing.T) {
dir := t.TempDir()
// Write an initial extension file.
extFile := filepath.Join(dir, "test.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Modify the file.
time.Sleep(50 * time.Millisecond) // let watcher settle
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
// Wait for debounce (300ms) + margin.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_IgnoresNonGoFiles(t *testing.T) {
dir := t.TempDir()
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Write a non-.go file.
time.Sleep(50 * time.Millisecond)
txtFile := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(txtFile, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// Wait past the debounce window.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 0 {
t.Errorf("expected 0 reloads for .txt file, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_Debounces(t *testing.T) {
dir := t.TempDir()
extFile := filepath.Join(dir, "ext.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
time.Sleep(50 * time.Millisecond)
// Rapid-fire writes (simulating editor save: write temp, rename, etc.).
for range 5 {
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
// Wait for debounce to fire.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 debounced reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatchedDirs_Deduplicates(t *testing.T) {
dir := t.TempDir()
dirs := WatchedDirs([]string{dir, dir})
count := 0
for _, d := range dirs {
abs, _ := filepath.Abs(dir)
if d == abs {
count++
}
}
if count != 1 {
t.Errorf("expected directory to appear once, got %d", count)
}
}
func TestWatchedDirs_FileParent(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "ext.go")
if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
dirs := WatchedDirs([]string{file})
abs, _ := filepath.Abs(dir)
found := false
for _, d := range dirs {
if d == abs {
found = true
}
}
if !found {
t.Errorf("expected parent dir %s in watched dirs %v", abs, dirs)
}
}
+50 -7
View File
@@ -40,6 +40,27 @@ type AgentSetupOptions struct {
// wrapping. Used by the SDK hook system. Both wrappers compose:
// extension wrapper runs first (inner), then this wrapper (outer).
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ProviderConfig, when non-nil, is used directly instead of calling
// BuildProviderConfig(). Callers that already hold viperInitMu can
// pre-build this and release the lock before calling SetupAgent, so the
// slow agent/MCP initialisation runs concurrently with other New() calls.
ProviderConfig *models.ProviderConfig
// Debug enables debug logging. When zero-value, viper is consulted.
// Only meaningful when ProviderConfig is also set.
Debug bool
// NoExtensions skips extension loading. When false, viper is consulted.
// Only meaningful when ProviderConfig is also set.
NoExtensions bool
// MaxSteps overrides the agent step limit. 0 means use viper value.
// Only meaningful when ProviderConfig is also set.
MaxSteps int
// StreamingEnabled controls streaming. Only meaningful when ProviderConfig
// is also set.
StreamingEnabled bool
// AuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports are configured with OAuth support.
AuthHandler tools.MCPAuthHandler
}
// AgentSetupResult bundles the created agent and any debug logger so the caller
@@ -88,15 +109,36 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
// SetupAgent creates an agent from the current viper state + the provided
// options. It wraps BuildProviderConfig and agent.CreateAgent.
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
modelConfig, systemPrompt, err := BuildProviderConfig()
if err != nil {
return nil, err
var modelConfig *models.ProviderConfig
var systemPrompt string
if opts.ProviderConfig != nil {
// Pre-built config supplied by caller (e.g. Kit.New after releasing
// viperInitMu). Use it directly — no viper reads needed here.
modelConfig = opts.ProviderConfig
systemPrompt = modelConfig.SystemPrompt
} else {
var err error
modelConfig, systemPrompt, err = BuildProviderConfig()
if err != nil {
return nil, err
}
}
// Resolve debug / no-extensions / max-steps / streaming: prefer explicit
// fields (set when ProviderConfig was pre-built) over viper fallback.
debugEnabled := opts.Debug || viper.GetBool("debug")
noExtensions := opts.NoExtensions || viper.GetBool("no-extensions")
maxSteps := opts.MaxSteps
if maxSteps == 0 {
maxSteps = viper.GetInt("max-steps")
}
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
// Create the appropriate debug logger.
var debugLogger tools.DebugLogger
var bufferedLogger *tools.BufferedDebugLogger
if viper.GetBool("debug") {
if debugEnabled {
if opts.UseBufferedLogger {
bufferedLogger = tools.NewBufferedDebugLogger(true)
debugLogger = bufferedLogger
@@ -108,7 +150,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
// Load extensions unless --no-extensions is set.
var extRunner *extensions.Runner
var extCreationOpts extensionCreationOpts
if !viper.GetBool("no-extensions") {
if !noExtensions {
var extErr error
extRunner, extCreationOpts, extErr = loadExtensions()
if extErr != nil {
@@ -140,12 +182,13 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
ModelConfig: modelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: systemPrompt,
MaxSteps: viper.GetInt("max-steps"),
StreamingEnabled: viper.GetBool("stream"),
MaxSteps: maxSteps,
StreamingEnabled: streamingEnabled,
ShowSpinner: opts.ShowSpinner,
Quiet: opts.Quiet,
SpinnerFunc: opts.SpinnerFunc,
DebugLogger: debugLogger,
AuthHandler: opts.AuthHandler,
CoreTools: opts.CoreTools,
ToolWrapper: toolWrapper,
ExtraTools: extraTools,
+4
View File
@@ -37,6 +37,8 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
Attachment: cfg.Attachment,
Reasoning: cfg.Reasoning,
Temperature: cfg.Temperature,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
Cost: Cost{
Input: cfg.Cost.Input,
Output: cfg.Cost.Output,
@@ -52,6 +54,8 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
// This is a duplicate here to avoid circular dependencies with internal/config.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
+21 -134
View File
@@ -10,7 +10,6 @@ import (
"maps"
"net/http"
"os"
"regexp"
"strings"
"time"
@@ -525,13 +524,13 @@ func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantas
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
switch level {
case ThinkingMinimal:
return openai.ReasoningEffortOption(openai.ReasoningEffortMinimal)
return new(openai.ReasoningEffortMinimal)
case ThinkingLow:
return openai.ReasoningEffortOption(openai.ReasoningEffortLow)
return new(openai.ReasoningEffortLow)
case ThinkingMedium:
return openai.ReasoningEffortOption(openai.ReasoningEffortMedium)
return new(openai.ReasoningEffortMedium)
case ThinkingHigh:
return openai.ReasoningEffortOption(openai.ReasoningEffortHigh)
return new(openai.ReasoningEffortHigh)
default:
return nil
}
@@ -1000,139 +999,29 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model}, nil
}
// thinkTagRegex matches <think>...</think> tags for extracting reasoning content
// from models that wrap thinking in XML-like tags (e.g., Qwen, DeepSeek).
var thinkTagRegex = regexp.MustCompile(`(?s)<think>(.*?)</think>`)
// customExtraContentFunc extracts reasoning from <think> tags in the content field.
// This handles models like Qwen and DeepSeek that return reasoning wrapped in XML tags
// rather than using a separate reasoning_content field.
func customExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
var content []fantasy.Content
if choice.Message.Content == "" {
return content
}
// Check for <think> tags in the content
matches := thinkTagRegex.FindStringSubmatch(choice.Message.Content)
if len(matches) > 1 {
// Found reasoning content in <think> tags
reasoning := strings.TrimSpace(matches[1])
if reasoning != "" {
content = append(content, fantasy.ReasoningContent{
Text: reasoning,
})
}
}
return content
}
// customStreamExtraFunc handles streaming responses with <think> tags.
// It extracts reasoning content and emits proper reasoning events.
func customStreamExtraFunc(
chunk openaisdk.ChatCompletionChunk,
yield func(fantasy.StreamPart) bool,
ctx map[string]any,
) (map[string]any, bool) {
if len(chunk.Choices) == 0 {
return ctx, true
}
const reasoningStartedKey = "reasoning_started"
const reasoningBufferKey = "reasoning_buffer"
const inThinkTagKey = "in_think_tag"
reasoningStarted, _ := ctx[reasoningStartedKey].(bool)
inThinkTag, _ := ctx[inThinkTagKey].(bool)
reasoningBuffer, _ := ctx[reasoningBufferKey].(string)
for i, choice := range chunk.Choices {
content := choice.Delta.Content
if content == "" {
continue
}
// Check for <think> tag start
if strings.Contains(content, "<think>") {
inThinkTag = true
ctx[inThinkTagKey] = true
// Emit reasoning start event
if !reasoningStarted {
reasoningStarted = true
ctx[reasoningStartedKey] = true
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningStart,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
}
// Extract content after <think>
parts := strings.SplitN(content, "<think>", 2)
if len(parts) > 1 && parts[1] != "" {
reasoningBuffer += parts[1]
ctx[reasoningBufferKey] = reasoningBuffer
}
continue
}
// Check for </think> tag end
if strings.Contains(content, "</think>") {
inThinkTag = false
ctx[inThinkTagKey] = false
// Extract content before </think>
parts := strings.SplitN(content, "</think>", 2)
if len(parts) > 0 {
reasoningBuffer += parts[0]
}
// Emit the accumulated reasoning
if reasoningBuffer != "" {
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningDelta,
ID: fmt.Sprintf("%d", i),
Delta: reasoningBuffer,
}) {
return ctx, false
}
ctx[reasoningBufferKey] = ""
}
// Emit reasoning end
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningEnd,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
continue
}
// Accumulate reasoning content while in think tag
if inThinkTag {
reasoningBuffer += content
ctx[reasoningBufferKey] = reasoningBuffer
}
}
return ctx, true
}
// customToPromptFunc converts prompts to OpenAI format using the default conversion.
func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
return openai.DefaultToPrompt(prompt, systemPrompt, user)
}
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
if config.ProviderURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url")
// Resolve base URL: per-model override > global provider-url flag/config
registry := GetGlobalRegistry()
modelInfo := registry.LookupModel("custom", modelName)
baseURL := config.ProviderURL
if modelInfo != nil && modelInfo.BaseURL != "" {
baseURL = modelInfo.BaseURL
}
if baseURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url or a baseUrl in the model config")
}
apiKey := config.ProviderAPIKey
if modelInfo != nil && modelInfo.APIKey != "" {
apiKey = modelInfo.APIKey
}
if apiKey == "" {
apiKey = os.Getenv("CUSTOM_API_KEY")
}
@@ -1141,15 +1030,13 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
apiKey = "custom"
}
// Use the openai provider directly with custom hooks to handle <think> tags
// from models like Qwen and DeepSeek that wrap reasoning in XML tags.
// <think> tag extraction is handled transparently at the agent layer,
// so no provider-level hooks are needed here.
var opts []openai.Option
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
opts = append(opts, openai.WithBaseURL(baseURL))
opts = append(opts, openai.WithAPIKey(apiKey))
opts = append(opts, openai.WithName("custom"))
opts = append(opts, openai.WithLanguageModelOptions(
openai.WithLanguageModelExtraContentFunc(customExtraContentFunc),
openai.WithLanguageModelStreamExtraFunc(customStreamExtraFunc),
openai.WithLanguageModelToPromptFunc(customToPromptFunc),
))
+50 -2
View File
@@ -24,6 +24,8 @@ type ModelInfo struct {
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
BaseURL string // Per-model base URL override (custom models only)
APIKey string // Per-model API key override (custom models only)
}
// SupportsCaching returns true if this model family supports prompt caching.
@@ -367,8 +369,8 @@ func (r *ModelsRegistry) GetFantasyProviders() []string {
// isProviderLLMSupported checks if a provider can be used with the LLM layer.
func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// Ollama is always supported (via openaicompat pointed at localhost)
if providerID == "ollama" {
// Ollama and custom are always supported (model names are user-defined).
if providerID == "ollama" || providerID == "custom" {
return true
}
@@ -400,6 +402,52 @@ func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
return &info
}
// ValidateModelString checks whether a model string is well-formed and refers
// to a known provider. It returns a user-friendly error with suggestions when
// the model or provider is unrecognised. Passing validation does not guarantee
// that API authentication will succeed — it only catches obvious mistakes
// (typos, missing provider prefix, non-existent provider names) early so that
// callers such as subagent spawning can return fast feedback.
//
// Unknown models under a known provider are allowed (the provider API is the
// authority), but a completely unknown provider is rejected.
func (r *ModelsRegistry) ValidateModelString(modelString string) error {
provider, modelName, err := ParseModelString(modelString)
if err != nil {
return err
}
// Ollama and custom are always valid — model names are user-defined.
if provider == "ollama" || provider == "custom" {
return nil
}
// Check if the provider exists in the registry.
providerInfo := r.GetProviderInfo(provider)
if providerInfo == nil {
known := r.GetSupportedProviders()
return fmt.Errorf(
"unknown provider %q in model string %q. Known providers: %s",
provider, modelString, strings.Join(known, ", "),
)
}
// Provider exists — check if the model is known. An unknown model is
// only a warning (the provider API decides), but we surface suggestions
// so the caller can self-correct.
if r.LookupModel(provider, modelName) == nil {
if suggestions := r.SuggestModels(provider, modelName); len(suggestions) > 0 {
return fmt.Errorf(
"model %q not found for provider %s. Did you mean one of: %s",
modelName, provider, strings.Join(suggestions, ", "),
)
}
// No suggestions — let it through; the provider API is the authority.
}
return nil
}
// Global registry instance
var globalRegistry = NewModelsRegistry()
+92
View File
@@ -0,0 +1,92 @@
package models
import (
"strings"
"testing"
)
func TestValidateModelString(t *testing.T) {
registry := GetGlobalRegistry()
tests := []struct {
name string
model string
wantErr bool
errSubstr string // expected substring in error message (empty = don't check)
}{
{
name: "valid anthropic model",
model: "anthropic/claude-sonnet-4-6",
wantErr: false,
},
{
name: "missing provider prefix",
model: "claude-sonnet-4-6",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "empty string",
model: "",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "unknown provider",
model: "fakeprovider/some-model",
wantErr: true,
errSubstr: "unknown provider",
},
{
name: "ollama always valid",
model: "ollama/llama3",
wantErr: false,
},
{
name: "custom always valid",
model: "custom/my-fine-tune",
wantErr: false,
},
{
name: "empty provider",
model: "/claude-sonnet-4-6",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "empty model name",
model: "anthropic/",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "unknown model under known provider (no suggestions)",
model: "anthropic/totally-unknown-xyz-999",
wantErr: false, // no suggestions → passes through
},
{
name: "typo model under known provider with suggestions",
model: "anthropic/claude-sonet", // misspelled "sonnet"
wantErr: true,
errSubstr: "Did you mean",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := registry.ValidateModelString(tt.model)
if tt.wantErr && err == nil {
t.Errorf("ValidateModelString(%q) = nil, want error", tt.model)
}
if !tt.wantErr && err != nil {
t.Errorf("ValidateModelString(%q) = %v, want nil", tt.model, err)
}
if tt.errSubstr != "" && err != nil {
if !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("ValidateModelString(%q) error = %q, want substring %q",
tt.model, err.Error(), tt.errSubstr)
}
}
})
}
}
+181
View File
@@ -114,6 +114,187 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
return tm, nil
}
// ForkToNewSession creates a new session file containing the history up to and
// including the target entry ID. This matches Pi's /fork behavior: it creates
// a completely new session file with a parent_session reference, copying all
// entries from the root to the target point.
func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManager, error) {
tm.mu.RLock()
defer tm.mu.RUnlock()
// Get the branch from root to target (root-to-leaf order).
branch := tm.getBranchLocked(targetID)
if len(branch) == 0 {
return nil, fmt.Errorf("target entry %q not found", targetID)
}
// Create a new session file.
newTm, err := CreateTreeSession(cwd)
if err != nil {
return nil, err
}
// Set the parent session reference in the header.
newTm.header.ParentSession = tm.filePath
newTm.header.ParentSessionID = tm.header.ID
// Rewrite the header with the parent reference.
// We need to close and recreate the file to rewrite the header.
if err := newTm.file.Close(); err != nil {
return nil, fmt.Errorf("failed to close new session file: %w", err)
}
// Recreate the file and write the updated header.
f, err := os.Create(newTm.filePath)
if err != nil {
return nil, fmt.Errorf("failed to recreate session file: %w", err)
}
newTm.file = f
if err := newTm.writeEntry(&newTm.header); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to write session header: %w", err)
}
// Copy entries from the branch to the new session.
// We need to remap IDs since the new session is independent.
idMap := make(map[string]string) // old ID -> new ID
var prevNewID string
for _, entry := range branch {
oldID := tm.EntryID(entry)
newID := GenerateEntryID()
idMap[oldID] = newID
// Create a copy of the entry with the new ID and remapped parent.
var newEntry any
switch e := entry.(type) {
case *MessageEntry:
newEntry = &MessageEntry{
Entry: Entry{
Type: EntryTypeMessage,
ID: newID,
ParentID: prevNewID, // Chain sequentially in new session
Timestamp: e.Timestamp,
},
Role: e.Role,
Parts: e.Parts,
Model: e.Model,
Provider: e.Provider,
}
// Copy label if present.
if label, ok := tm.labels[oldID]; ok {
newTm.labels[newID] = label
}
case *ModelChangeEntry:
newEntry = &ModelChangeEntry{
Entry: Entry{
Type: EntryTypeModelChange,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Provider: e.Provider,
ModelID: e.ModelID,
}
case *LabelEntry:
// Remap the target ID if it's in our copied branch.
newTargetID := e.TargetID
if mapped, ok := idMap[e.TargetID]; ok {
newTargetID = mapped
}
newEntry = &LabelEntry{
Entry: Entry{
Type: EntryTypeLabel,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
TargetID: newTargetID,
Label: e.Label,
}
case *SessionInfoEntry:
newEntry = &SessionInfoEntry{
Entry: Entry{
Type: EntryTypeSessionInfo,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Name: e.Name,
}
newTm.sessionName = e.Name
case *ExtensionDataEntry:
newEntry = &ExtensionDataEntry{
Entry: Entry{
Type: EntryTypeExtensionData,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
ExtType: e.ExtType,
Data: e.Data,
}
case *BranchSummaryEntry:
// Remap the from ID if it's in our copied branch.
newFromID := e.FromID
if mapped, ok := idMap[e.FromID]; ok {
newFromID = mapped
}
newEntry = &BranchSummaryEntry{
Entry: Entry{
Type: EntryTypeBranchSummary,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
FromID: newFromID,
Summary: e.Summary,
}
case *CompactionEntry:
// Remap the first kept entry ID if it's in our copied branch.
newFirstKeptID := e.FirstKeptEntryID
if mapped, ok := idMap[e.FirstKeptEntryID]; ok {
newFirstKeptID = mapped
}
newEntry = &CompactionEntry{
Entry: Entry{
Type: EntryTypeCompaction,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Summary: e.Summary,
FirstKeptEntryID: newFirstKeptID,
TokensBefore: e.TokensBefore,
TokensAfter: e.TokensAfter,
MessagesRemoved: e.MessagesRemoved,
ReadFiles: e.ReadFiles,
ModifiedFiles: e.ModifiedFiles,
}
}
if newEntry != nil {
if err := newTm.appendAndPersist(newEntry); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to copy entry: %w", err)
}
prevNewID = newID
}
}
// Set the leaf to the last entry in the new session.
newTm.leafID = prevNewID
return newTm, nil
}
// OpenTreeSession opens an existing JSONL session file.
func OpenTreeSession(path string) (*TreeManager, error) {
data, err := os.ReadFile(path)
+84 -10
View File
@@ -68,6 +68,7 @@ type MCPConnectionPool struct {
cancel context.CancelFunc
debug bool
debugLogger DebugLogger
oauthFlow *OAuthFlowRunner
}
// NewMCPConnectionPool creates a new MCP connection pool with the specified configuration.
@@ -75,7 +76,7 @@ type MCPConnectionPool struct {
// goroutine for periodic health checks that runs until Close is called.
// The model parameter is used for MCP servers that require sampling support.
// Thread-safe for concurrent use immediately after creation.
func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageModel, debug bool) *MCPConnectionPool {
func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageModel, debug bool, authHandler MCPAuthHandler) *MCPConnectionPool {
if config == nil {
config = DefaultConnectionPoolConfig()
}
@@ -90,6 +91,10 @@ func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageMo
debug: debug,
}
if authHandler != nil {
pool.oauthFlow = NewOAuthFlowRunner(authHandler)
}
go pool.startHealthCheck()
return pool
}
@@ -103,6 +108,15 @@ func (p *MCPConnectionPool) SetDebugLogger(logger DebugLogger) {
p.debugLogger = logger
}
// SetOAuthFlow sets the OAuth flow runner for the connection pool.
// When set, the pool can trigger OAuth re-authorization when a tool call fails
// with an OAuth error (e.g. expired token). Thread-safe and can be called at any time.
func (p *MCPConnectionPool) SetOAuthFlow(flow *OAuthFlowRunner) {
p.mu.Lock()
defer p.mu.Unlock()
p.oauthFlow = flow
}
// GetConnection retrieves or creates a connection for the specified MCP server.
// If a healthy, non-idle connection exists in the pool, it will be reused.
// Otherwise, a new connection is created and added to the pool.
@@ -230,18 +244,43 @@ func (p *MCPConnectionPool) performHealthCheck(ctx context.Context, conn *MCPCon
// createConnection creates a new connection
func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (*MCPConnection, error) {
client, err := p.createMCPClient(ctx, serverName, serverConfig)
mcpClient, err := p.createMCPClient(ctx, serverName, serverConfig)
if err != nil {
return nil, err
// SSE transport can return OAuth error during Start()
if p.oauthFlow != nil && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
}
// Retry after successful auth
mcpClient, err = p.createMCPClient(ctx, serverName, serverConfig)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
if err := p.initializeClient(ctx, client); err != nil {
_ = client.Close()
return nil, err
if err := p.initializeClient(ctx, mcpClient); err != nil {
// Streamable HTTP transport returns OAuth error during Initialize()
if p.oauthFlow != nil && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
}
// Retry initialization after successful auth
if err := p.initializeClient(ctx, mcpClient); err != nil {
_ = mcpClient.Close()
return nil, err
}
} else {
_ = mcpClient.Close()
return nil, err
}
}
conn := &MCPConnection{
client: client,
client: mcpClient,
serverName: serverName,
serverConfig: serverConfig,
lastUsed: time.Now(),
@@ -323,13 +362,29 @@ func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig co
}
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. Client ID and
// scopes are discovered automatically via dynamic client registration and
// server metadata (RFC 9728).
if p.oauthFlow != nil {
tokenStore, tsErr := NewFileTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
}
options = append(options, transport.WithOAuth(transport.OAuthConfig{
RedirectURI: p.oauthFlow.handler.RedirectURI(),
PKCEEnabled: true,
TokenStore: tokenStore,
}))
}
sseClient, err := client.NewSSEMCPClient(serverConfig.URL, options...)
if err != nil {
return nil, err
}
if err := sseClient.Start(ctx); err != nil {
return nil, fmt.Errorf("failed to start SSE client: %v", err)
return nil, fmt.Errorf("failed to start SSE client: %w", err)
}
return sseClient, nil
@@ -354,13 +409,29 @@ func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverCo
}
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. Client ID and
// scopes are discovered automatically via dynamic client registration and
// server metadata (RFC 9728).
if p.oauthFlow != nil {
tokenStore, tsErr := NewFileTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
}
options = append(options, transport.WithHTTPOAuth(transport.OAuthConfig{
RedirectURI: p.oauthFlow.handler.RedirectURI(),
PKCEEnabled: true,
TokenStore: tokenStore,
}))
}
streamableClient, err := client.NewStreamableHttpClient(serverConfig.URL, options...)
if err != nil {
return nil, err
}
if err := streamableClient.Start(ctx); err != nil {
return nil, fmt.Errorf("failed to start streamable HTTP client: %v", err)
return nil, fmt.Errorf("failed to start streamable HTTP client: %w", err)
}
return streamableClient, nil
@@ -381,7 +452,7 @@ func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.
_, err := client.Initialize(initCtx, initRequest)
if err != nil {
return fmt.Errorf("initialization timeout or failed: %v", err)
return fmt.Errorf("initialization timeout or failed: %w", err)
}
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
@@ -539,6 +610,9 @@ func (p *MCPConnectionPool) Close() error {
// isConnectionError checks if the error is connection-related
func isConnectionError(err error) bool {
if IsOAuthError(err) {
return false // OAuth errors are recoverable, not connection failures
}
errStr := err.Error()
return strings.Contains(errStr, "Connection not found") ||
strings.Contains(errStr, "transport error") ||
+24 -3
View File
@@ -59,9 +59,30 @@ func (t *mcpFantasyTool) Run(ctx context.Context, call fantasy.ToolCall) (fantas
},
})
if err != nil {
// Mark connection as unhealthy for automatic recovery
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool: %w", err)
// Handle OAuth re-authorization: token may have expired mid-session.
if t.mapping.manager.connectionPool.oauthFlow != nil && IsOAuthError(err) {
if flowErr := t.mapping.manager.connectionPool.oauthFlow.RunAuthFlow(ctx, t.mapping.serverName, err); flowErr != nil {
return fantasy.ToolResponse{}, fmt.Errorf("OAuth re-authorization failed for tool %s: %w", t.mapping.originalName, flowErr)
}
// Retry the tool call after successful re-auth.
result, err = conn.client.CallTool(ctx, mcp.CallToolRequest{
Request: mcp.Request{
Method: "tools/call",
},
Params: mcp.CallToolParams{
Name: t.mapping.originalName,
Arguments: arguments,
},
})
if err != nil {
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool after re-auth: %w", err)
}
} else {
// Mark connection as unhealthy for automatic recovery
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool: %w", err)
}
}
// Marshal the MCP result to JSON string
+10 -1
View File
@@ -22,6 +22,7 @@ type MCPToolManager struct {
tools []fantasy.AgentTool
toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name
model fantasy.LanguageModel // LLM model for sampling
authHandler MCPAuthHandler // OAuth handler for remote servers (nil = no OAuth)
config *config.Config
debug bool
debugLogger DebugLogger
@@ -53,6 +54,14 @@ func (m *MCPToolManager) SetModel(model fantasy.LanguageModel) {
m.model = model
}
// SetAuthHandler sets the OAuth handler for remote MCP server authentication.
// When set, remote transports (streamable HTTP, SSE) are configured with OAuth
// support, enabling automatic authorization flows when servers require authentication.
// This method should be called before LoadTools.
func (m *MCPToolManager) SetAuthHandler(handler MCPAuthHandler) {
m.authHandler = handler
}
// SetDebugLogger sets the debug logger for the tool manager.
// The logger will be used to output detailed debugging information about MCP connections,
// tool loading, and execution. If a connection pool exists, it will also be configured
@@ -76,7 +85,7 @@ func (m *MCPToolManager) LoadTools(ctx context.Context, config *config.Config) e
if m.debugLogger == nil {
m.debugLogger = NewSimpleDebugLogger(config.Debug)
}
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, config.Debug)
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, config.Debug, m.authHandler)
m.connectionPool.SetDebugLogger(m.debugLogger)
var loadErrors []string
+109
View File
@@ -0,0 +1,109 @@
package tools
import (
"context"
"fmt"
"net/url"
"github.com/mark3labs/mcp-go/client"
)
// MCPAuthHandler is the internal interface for handling MCP OAuth flows.
// The SDK-level kit.MCPAuthHandler is adapted to this interface in cmd/root.go
// or pkg/kit/kit.go, keeping the tools package decoupled from the SDK.
type MCPAuthHandler interface {
// RedirectURI returns the OAuth redirect URI for transport setup.
RedirectURI() string
// HandleAuth is called when a server requires OAuth authorization.
// It receives the server name and the authorization URL the user must visit.
// It returns the full callback URL (containing code and state query params)
// after the user completes authorization.
HandleAuth(ctx context.Context, serverName string, authURL string) (callbackURL string, err error)
}
// OAuthFlowRunner handles the OAuth authorization flow when an MCP server
// returns an OAuthAuthorizationRequiredError. It coordinates dynamic client
// registration, PKCE generation, user authorization (via MCPAuthHandler),
// and token exchange.
type OAuthFlowRunner struct {
handler MCPAuthHandler
}
// NewOAuthFlowRunner creates a new OAuthFlowRunner with the given auth handler.
func NewOAuthFlowRunner(handler MCPAuthHandler) *OAuthFlowRunner {
return &OAuthFlowRunner{handler: handler}
}
// RunAuthFlow executes the OAuth authorization flow for the given server.
// It extracts the OAuthHandler from the error, performs dynamic client registration
// if needed, generates PKCE parameters, delegates to the MCPAuthHandler for user
// interaction, and exchanges the authorization code for a token.
func (r *OAuthFlowRunner) RunAuthFlow(ctx context.Context, serverName string, authErr error) error {
// Extract the OAuthHandler from the authorization-required error.
oauthHandler := client.GetOAuthHandler(authErr)
if oauthHandler == nil {
return fmt.Errorf("oauth flow: failed to extract OAuth handler from error: %w", authErr)
}
// Perform dynamic client registration if no client ID is configured yet.
if oauthHandler.GetClientID() == "" {
if err := oauthHandler.RegisterClient(ctx, "kit"); err != nil {
return fmt.Errorf("oauth flow: dynamic client registration failed: %w", err)
}
}
// Generate PKCE code verifier and challenge.
codeVerifier, err := client.GenerateCodeVerifier()
if err != nil {
return fmt.Errorf("oauth flow: failed to generate code verifier: %w", err)
}
codeChallenge := client.GenerateCodeChallenge(codeVerifier)
// Generate a random state parameter for CSRF protection.
state, err := client.GenerateState()
if err != nil {
return fmt.Errorf("oauth flow: failed to generate state: %w", err)
}
// Build the authorization URL the user needs to visit.
authURL, err := oauthHandler.GetAuthorizationURL(ctx, state, codeChallenge)
if err != nil {
return fmt.Errorf("oauth flow: failed to get authorization URL: %w", err)
}
// Delegate to the MCPAuthHandler for user-facing authorization (e.g. open
// browser, wait for redirect). It returns the full callback URL containing
// the authorization code and state.
callbackURL, err := r.handler.HandleAuth(ctx, serverName, authURL)
if err != nil {
return fmt.Errorf("oauth flow: user authorization failed: %w", err)
}
// Parse the callback URL to extract the authorization code and state.
parsed, err := url.Parse(callbackURL)
if err != nil {
return fmt.Errorf("oauth flow: failed to parse callback URL: %w", err)
}
code := parsed.Query().Get("code")
returnedState := parsed.Query().Get("state")
if code == "" {
return fmt.Errorf("oauth flow: callback URL missing 'code' parameter")
}
if returnedState == "" {
return fmt.Errorf("oauth flow: callback URL missing 'state' parameter")
}
// Exchange the authorization code for an access token.
if err := oauthHandler.ProcessAuthorizationResponse(ctx, code, returnedState, codeVerifier); err != nil {
return fmt.Errorf("oauth flow: token exchange failed: %w", err)
}
return nil
}
// IsOAuthError returns true if the error is an OAuthAuthorizationRequiredError.
func IsOAuthError(err error) bool {
return client.IsOAuthAuthorizationRequiredError(err)
}
+155
View File
@@ -0,0 +1,155 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/mark3labs/mcp-go/client/transport"
)
// Compile-time check that FileTokenStore implements transport.TokenStore.
var _ transport.TokenStore = (*FileTokenStore)(nil)
// FileTokenStore is a file-backed implementation of transport.TokenStore that
// persists OAuth tokens as JSON on disk. Tokens are stored in a shared JSON file
// keyed by server URL, allowing multiple MCP servers to maintain independent tokens.
//
// The token file is located at $XDG_CONFIG_HOME/.kit/mcp_tokens.json, falling back
// to ~/.config/.kit/mcp_tokens.json when XDG_CONFIG_HOME is not set.
//
// FileTokenStore is safe for concurrent use.
type FileTokenStore struct {
serverKey string
filePath string
mu sync.RWMutex
}
// NewFileTokenStore creates a new FileTokenStore for the given server URL.
// The serverKey is used as the map key in the shared token file, and should
// typically be the MCP server's base URL.
//
// Returns an error if the token file path cannot be resolved.
func NewFileTokenStore(serverKey string) (*FileTokenStore, error) {
filePath, err := resolveTokenFilePath()
if err != nil {
return nil, fmt.Errorf("resolving token file path: %w", err)
}
return &FileTokenStore{
serverKey: serverKey,
filePath: filePath,
}, nil
}
// GetToken returns the stored token for this store's server key.
// Returns transport.ErrNoToken if no token exists for the server key or if
// the token file does not yet exist.
// Returns context.Canceled or context.DeadlineExceeded if the context is done.
func (s *FileTokenStore) GetToken(ctx context.Context) (*transport.Token, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
tokens, err := readTokenFile(s.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, transport.ErrNoToken
}
return nil, fmt.Errorf("reading token file: %w", err)
}
token, ok := tokens[s.serverKey]
if !ok {
return nil, transport.ErrNoToken
}
return token, nil
}
// SaveToken persists the given token for this store's server key.
// If the token file or its parent directories do not exist, they are created.
// Existing tokens for other server keys are preserved.
// Returns context.Canceled or context.DeadlineExceeded if the context is done.
func (s *FileTokenStore) SaveToken(ctx context.Context, token *transport.Token) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
tokens, err := readTokenFile(s.filePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading token file: %w", err)
}
if tokens == nil {
tokens = make(map[string]*transport.Token)
}
tokens[s.serverKey] = token
if err := writeTokenFile(s.filePath, tokens); err != nil {
return fmt.Errorf("writing token file: %w", err)
}
return nil
}
// resolveTokenFilePath determines the path to the token file using
// XDG_CONFIG_HOME if set, otherwise falling back to ~/.config/.kit/.
func resolveTokenFilePath() (string, error) {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("determining user home directory: %w", err)
}
configDir = filepath.Join(home, ".config")
}
return filepath.Join(configDir, ".kit", "mcp_tokens.json"), nil
}
// readTokenFile reads and unmarshals the token file into a server-keyed map.
// Returns os.ErrNotExist (via os.IsNotExist) if the file does not exist.
func readTokenFile(path string) (map[string]*transport.Token, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tokens map[string]*transport.Token
if err := json.Unmarshal(data, &tokens); err != nil {
return nil, fmt.Errorf("unmarshaling token file: %w", err)
}
return tokens, nil
}
// writeTokenFile marshals the token map and writes it to disk, creating
// parent directories as needed. The file is written with 0600 permissions
// to protect sensitive token data.
func writeTokenFile(path string, tokens map[string]*transport.Token) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("creating token directory %s: %w", dir, err)
}
data, err := json.MarshalIndent(tokens, "", " ")
if err != nil {
return fmt.Errorf("marshaling tokens: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("writing token file %s: %w", path, err)
}
return nil
}
+3 -1
View File
@@ -4,6 +4,8 @@ import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// blockRenderer handles rendering of content blocks with configurable options
@@ -175,7 +177,7 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
borderChars = 1
}
theme := GetTheme()
theme := style.GetTheme()
// Resolve foreground color: caller override or theme default.
fgColor := theme.Text
+6 -5
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/ui/core"
)
// ==========================================================================
@@ -59,7 +60,7 @@ func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) {
t.Fatal("expected a cmd from pressing enter on non-empty input")
}
sm, ok := msg.(submitMsg)
sm, ok := msg.(core.SubmitMsg)
if !ok {
t.Fatalf("expected submitMsg, got %T", msg)
}
@@ -83,7 +84,7 @@ func TestInputComponent_CtrlD_SubmitEmitsSubmitMsg(t *testing.T) {
if msg == nil {
t.Fatal("expected a cmd from ctrl+d on non-empty input")
}
sm, ok := msg.(submitMsg)
sm, ok := msg.(core.SubmitMsg)
if !ok {
t.Fatalf("expected submitMsg from ctrl+d, got %T", msg)
}
@@ -175,7 +176,7 @@ func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
}
msg := runCmd(cmd)
sm, ok := msg.(submitMsg)
sm, ok := msg.(core.SubmitMsg)
if !ok {
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
}
@@ -230,7 +231,7 @@ func TestInputComponent_ClearQueue_ForwardsAsSubmitMsg(t *testing.T) {
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
}
msg := runCmd(cmd)
sm, ok := msg.(submitMsg)
sm, ok := msg.(core.SubmitMsg)
if !ok {
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
}
@@ -258,7 +259,7 @@ func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
if msg == nil {
t.Fatal("expected submitMsg for unknown slash command")
}
sm, ok := msg.(submitMsg)
sm, ok := msg.(core.SubmitMsg)
if !ok {
t.Fatalf("expected submitMsg for unknown slash command, got %T", msg)
}
+3 -1
View File
@@ -8,6 +8,8 @@ import (
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"golang.org/x/term"
"github.com/mark3labs/kit/internal/ui/style"
)
// CLI manages the command-line interface for KIT, providing message rendering,
@@ -125,7 +127,7 @@ func (c *CLI) DisplayInfo(message string) {
// DisplayExtensionBlock renders a custom styled block with the given border
// color and optional subtitle. Used by extensions via ctx.PrintBlock.
func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
theme := GetTheme()
theme := style.GetTheme()
borderClr := theme.Info
if borderColor != "" {
-96
View File
@@ -1,96 +0,0 @@
package ui
import (
"fmt"
"runtime"
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
)
// CopyToClipboard writes text to both the system clipboard and via OSC 52.
// Returns a tea.Cmd that can be used in Bubble Tea's Update flow.
func CopyToClipboard(text string) tea.Cmd {
if text == "" {
return nil
}
return tea.Sequence(
// Method 1: OSC 52 escape sequence (works in modern terminals)
tea.SetClipboard(text),
// Method 2: Native system clipboard (atotto/clipboard)
func() tea.Msg {
// Best effort - ignore errors
_ = clipboard.WriteAll(text)
return nil
},
)
}
// CopyToClipboardWithMessage writes text to clipboard and returns a toast notification.
func CopyToClipboardWithMessage(text string, message string) tea.Cmd {
if text == "" {
return nil
}
return tea.Sequence(
CopyToClipboard(text),
func() tea.Msg {
return ToastMsg{Message: message, Type: ToastInfo}
},
)
}
// ToastType represents the type of toast notification.
type ToastType int
const (
ToastInfo ToastType = iota
ToastSuccess
ToastWarning
ToastError
)
// ToastMsg is a message to display a toast notification.
type ToastMsg struct {
Message string
Type ToastType
}
// IsClipboardSupported returns true if the clipboard is supported on this platform.
func IsClipboardSupported() bool {
// atotto/clipboard supports Linux (with xclip or xsel), macOS, Windows
switch runtime.GOOS {
case "darwin", "windows":
return true
case "linux":
// Check if xclip or xsel is available
// This is a best-effort check
return true
default:
return false
}
}
// CopySelection represents a text selection with start/end positions.
type CopySelection struct {
StartItemIdx int // Index of item where selection starts
StartLine int // Line within item where selection starts
StartCol int // Column where selection starts
EndItemIdx int // Index of item where selection ends
EndLine int // Line within item where selection ends
EndCol int // Column where selection ends
Active bool // Whether selection is currently active
}
// IsEmpty returns true if the selection has no content.
func (s CopySelection) IsEmpty() bool {
return !s.Active || (s.StartItemIdx == s.EndItemIdx && s.StartLine == s.EndLine && s.StartCol == s.EndCol)
}
// String returns a string representation for debugging.
func (s CopySelection) String() string {
return fmt.Sprintf("Selection{item:%d-%d, line:%d-%d, col:%d-%d, active:%v}",
s.StartItemIdx, s.EndItemIdx, s.StartLine, s.EndLine, s.StartCol, s.EndCol, s.Active)
}
+26
View File
@@ -0,0 +1,26 @@
package clipboard
import (
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
)
// CopyToClipboard writes text to both the system clipboard and via OSC 52.
// Returns a tea.Cmd that can be used in Bubble Tea's Update flow.
func CopyToClipboard(text string) tea.Cmd {
if text == "" {
return nil
}
return tea.Sequence(
// Method 1: OSC 52 escape sequence (works in modern terminals)
tea.SetClipboard(text),
// Method 2: Native system clipboard (atotto/clipboard)
func() tea.Msg {
// Best effort - ignore errors
_ = clipboard.WriteAll(text)
return nil
},
)
}
@@ -1,4 +1,4 @@
package ui
package commands
import (
"slices"
@@ -7,6 +7,10 @@ import (
"github.com/mark3labs/kit/internal/models"
)
// ListThemesFunc is set by the ui package to provide theme name completion.
// This breaks the circular dependency between commands and ui packages.
var ListThemesFunc func() []string
// SlashCommand represents a user-invokable slash command with its metadata.
// Commands can have multiple aliases and are organized by category for better
// discoverability and help display.
@@ -99,7 +103,10 @@ var SlashCommands = []SlashCommand{
Description: "Switch color theme (e.g. /theme catppuccin)",
Category: "System",
Complete: func(prefix string) []string {
names := ListThemes()
if ListThemesFunc == nil {
return nil
}
names := ListThemesFunc()
if prefix == "" {
return names
}
@@ -112,6 +119,12 @@ var SlashCommands = []SlashCommand{
return matches
},
},
{
Name: "/reload-ext",
Description: "Hot-reload all extensions from disk",
Category: "System",
Aliases: []string{"/re"},
},
{
Name: "/quit",
Description: "Exit the application",
@@ -127,7 +140,7 @@ var SlashCommands = []SlashCommand{
},
{
Name: "/fork",
Description: "Branch from an earlier message",
Description: "Fork to new session from an earlier message",
Category: "Navigation",
},
{
@@ -1,4 +1,4 @@
package ui
package core
// ImageAttachment holds a clipboard image that will be sent alongside the
// user's text prompt to the LLM. The data is raw image bytes; MediaType is
@@ -10,9 +10,9 @@ type ImageAttachment struct {
MediaType string
}
// submitMsg is sent by the InputComponent when the user submits a text prompt.
// SubmitMsg is sent by the InputComponent when the user submits a text prompt.
// The parent model receives this and calls app.Run(Text) to start agent processing.
type submitMsg struct {
type SubmitMsg struct {
// Text is the user's input text to send to the agent.
Text string
// Images holds clipboard image attachments to send alongside the text.
@@ -20,10 +20,10 @@ type submitMsg struct {
Images []ImageAttachment
}
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
// CancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
// presses ESC once during stateWorking. If this message arrives before the user
// presses ESC a second time, the canceling state is reset to false.
type cancelTimerExpiredMsg struct{}
type CancelTimerExpiredMsg struct{}
// --- Tree session events ---
@@ -42,14 +42,14 @@ type TreeNodeSelectedMsg struct {
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
type TreeCancelledMsg struct{}
// shellCommandMsg is sent by the InputComponent when the user submits a
// ShellCommandMsg is sent by the InputComponent when the user submits a
// ! or !! prefixed command. The parent model intercepts this to execute
// the shell command directly instead of forwarding to the LLM.
//
// Matching pi's behavior:
// - !cmd → run shell command, output INCLUDED in LLM context
// - !!cmd → run shell command, output EXCLUDED from LLM context
type shellCommandMsg struct {
type ShellCommandMsg struct {
// Command is the shell command to execute (prefix stripped).
Command string
// ExcludeFromContext is true for !! (output excluded from LLM context),
@@ -57,9 +57,9 @@ type shellCommandMsg struct {
ExcludeFromContext bool
}
// shellCommandResultMsg carries the result of a shell command execution
// ShellCommandResultMsg carries the result of a shell command execution
// back to the parent model for display.
type shellCommandResultMsg struct {
type ShellCommandResultMsg struct {
// Command is the original shell command that was executed.
Command string
// Output is the combined stdout/stderr output.
@@ -68,6 +68,6 @@ type shellCommandResultMsg struct {
ExitCode int
// Err is non-nil if the command failed to start or timed out.
Err error
// ExcludeFromContext mirrors the flag from shellCommandMsg.
// ExcludeFromContext mirrors the flag from ShellCommandMsg.
ExcludeFromContext bool
}
+62
View File
@@ -0,0 +1,62 @@
package ui
// This file re-exports types from subpackages for backward compatibility.
// External importers can continue using ui.XXX without needing to import
// from subpackages directly.
import (
"github.com/mark3labs/kit/internal/ui/commands"
"github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/fileutil"
"github.com/mark3labs/kit/internal/ui/prefs"
"github.com/mark3labs/kit/internal/ui/style"
)
// Re-export from core package
type (
ImageAttachment = core.ImageAttachment
SubmitMsg = core.SubmitMsg
CancelTimerExpiredMsg = core.CancelTimerExpiredMsg
TreeNodeSelectedMsg = core.TreeNodeSelectedMsg
TreeCancelledMsg = core.TreeCancelledMsg
ShellCommandMsg = core.ShellCommandMsg
ShellCommandResultMsg = core.ShellCommandResultMsg
)
// Re-export from commands package
type (
SlashCommand = commands.SlashCommand
ExtensionCommand = commands.ExtensionCommand
)
// Re-export functions from fileutil package
var ProcessFileAttachments = fileutil.ProcessFileAttachments
// Re-export from prefs package
var (
LoadThemePreference = prefs.LoadThemePreference
SaveThemePreference = prefs.SaveThemePreference
LoadModelPreference = prefs.LoadModelPreference
SaveModelPreference = prefs.SaveModelPreference
LoadThinkingLevelPreference = prefs.LoadThinkingLevelPreference
SaveThinkingLevelPreference = prefs.SaveThinkingLevelPreference
)
// Re-export from style package
type (
Theme = style.Theme
MarkdownThemeColors = style.MarkdownThemeColors
)
var (
GetTheme = style.GetTheme
SetTheme = style.SetTheme
DefaultTheme = style.DefaultTheme
ApplyTheme = style.ApplyTheme
ApplyThemeWithoutSave = style.ApplyThemeWithoutSave
ListThemes = style.ListThemes
RegisterThemeFromConfig = style.RegisterThemeFromConfig
KitBanner = style.KitBanner
AdaptiveColor = style.AdaptiveColor
IsDarkBackground = style.IsDarkBackground
)
@@ -1,4 +1,4 @@
package ui
package fileutil
import (
"fmt"
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// Renderer is the interface satisfied by MessageRenderer. It allows model.go
@@ -30,7 +32,7 @@ var _ Renderer = (*MessageRenderer)(nil)
// combined, styled output string with tags stripped.
//
// Shared by MessageRenderer.
func parseBashOutput(result string, theme Theme) string {
func parseBashOutput(result string, theme style.Theme) string {
var formattedResult strings.Builder
remaining := result
+5 -3
View File
@@ -2,20 +2,22 @@ package ui
import (
"strings"
"github.com/mark3labs/kit/internal/ui/commands"
)
// FuzzyMatch represents the result of a fuzzy string matching operation,
// containing the matched command and its relevance score. Higher scores
// indicate better matches.
type FuzzyMatch struct {
Command *SlashCommand
Command *commands.SlashCommand
Score int
}
// FuzzyMatchCommands performs fuzzy string matching on the provided slash commands
// based on the query string. Returns a slice of matches sorted by relevance score
// in descending order. An empty query returns all commands with zero scores.
func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch {
func FuzzyMatchCommands(query string, commands []commands.SlashCommand) []FuzzyMatch {
if query == "" || query == "/" {
// Return all commands when query is empty or just "/"
matches := make([]FuzzyMatch, len(commands))
@@ -57,7 +59,7 @@ func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch {
}
// fuzzyScore calculates the fuzzy match score for a command
func fuzzyScore(query string, cmd *SlashCommand) int {
func fuzzyScore(query string, cmd *commands.SlashCommand) int {
// Check exact match first
cmdName := strings.ToLower(strings.TrimPrefix(cmd.Name, "/"))
if cmdName == query {
+90 -54
View File
@@ -10,6 +10,9 @@ import (
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/clipboard"
"github.com/mark3labs/kit/internal/ui/commands"
"github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/style"
)
// InputComponent is the interactive text input field for the parent AppModel.
@@ -29,7 +32,7 @@ import (
// app.Run().
type InputComponent struct {
textarea textarea.Model
commands []SlashCommand
commands []commands.SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
@@ -42,17 +45,17 @@ type InputComponent struct {
// Argument completion state. When the user types "/cmd " followed by
// a partial argument and the command has a Complete function, the popup
// switches to argument-completion mode showing suggestions from Complete.
argMode bool // true when showing arg completions
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
argMode bool // true when showing arg completions
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []commands.SlashCommand // backing storage for synthetic arg entries
// File completion state. When the user types @ followed by a partial
// file path, the popup shows file/directory suggestions from the cwd.
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
@@ -71,7 +74,7 @@ type InputComponent struct {
// pendingImages holds clipboard images attached to the next submission.
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []ImageAttachment
pendingImages []core.ImageAttachment
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
@@ -94,7 +97,7 @@ const maxHistory = 100
// clipboardImageMsg is the result of an async clipboard image read.
type clipboardImageMsg struct {
image *ImageAttachment
image *core.ImageAttachment
err error
}
@@ -119,7 +122,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
)
// Style the textarea using theme colors.
theme := GetTheme()
theme := style.GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
@@ -130,7 +133,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
return &InputComponent{
textarea: ta,
commands: SlashCommands,
commands: commands.SlashCommands,
width: width,
popupHeight: 7,
title: title,
@@ -329,7 +332,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
@@ -337,7 +340,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if fs.IsDir {
desc = "directory"
}
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
@@ -396,14 +399,14 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
cmd := strings.TrimSpace(trimmed[2:])
if cmd != "" {
return func() tea.Msg {
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: true}
}
}
} else if strings.HasPrefix(trimmed, "!") {
cmd := strings.TrimSpace(trimmed[1:])
if cmd != "" {
return func() tea.Msg {
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: false}
}
}
}
@@ -413,7 +416,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
// /clear and /clear-queue) are forwarded to the parent model via
// submitMsg so the parent can update its own state (ScrollList, queue
// counts, etc.) in one place.
if sc := GetCommandByName(trimmed); sc != nil {
if sc := commands.GetCommandByName(trimmed); sc != nil {
switch sc.Name {
case "/quit":
return tea.Quit
@@ -426,7 +429,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
images := s.pendingImages
s.pendingImages = nil
return func() tea.Msg {
return submitMsg{Text: trimmed, Images: images}
return core.SubmitMsg{Text: trimmed, Images: images}
}
}
@@ -463,7 +466,7 @@ func (s *InputComponent) resetHistoryBrowsing() {
func (s *InputComponent) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
theme := style.GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
@@ -558,18 +561,39 @@ func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string {
// renderPopupWithOptions renders the popup content with optional center styling.
func (s *InputComponent) renderPopupWithOptions(centered bool) string {
theme := GetTheme()
theme := style.GetTheme()
popupWidth := max(s.width-4, 20)
// Use the theme background for the popup - the full-width item backgrounds
// and primary-colored selection will provide sufficient contrast
popupBg := theme.Background
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.MutedBorder).
BorderForeground(theme.Primary).
Background(popupBg).
Padding(1, 2).
Width(popupWidth).
MarginLeft(0)
MarginLeft(0).
MarginBottom(1) // Visual depth/shadow effect
// Inner content width: popup minus border (2) and horizontal padding (4).
innerWidth := max(popupWidth-6, 10)
// Item background styles for high contrast
normalItemBg := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.Text).
Width(innerWidth).
Padding(0, 1)
selectedItemBg := lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Background).
Width(innerWidth).
Padding(0, 1).
Bold(true)
var items []string
visibleItems := min(len(s.filtered), s.popupHeight)
@@ -583,44 +607,45 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
match := s.filtered[i]
sc := match.Command
// Choose the appropriate background style
itemStyle := normalItemBg
if i == s.selected {
itemStyle = selectedItemBg
}
// Build indicator with proper coloring
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().Foreground(theme.Primary).Render("> ")
indicator = "> "
} else {
indicator = " "
}
nameStyle := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
if i == s.selected {
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
// Build content with name and description
var content string
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
// File mode: use full width for the path, show description inline
maxNameLen := max(innerWidth-16, 8)
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" && innerWidth > 30 {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
content = indicator + displayName + " " + sc.Description
} else {
items = append(items, indicator+name)
content = indicator + displayName
}
} else {
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc.
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc
if innerWidth < 20 {
// Very narrow: show truncated name only, no fixed column.
// Very narrow: show truncated name only
displayName := sc.Name
maxName := max(innerWidth-2, 3)
if len(displayName) > maxName {
displayName = displayName[:maxName-1] + "…"
}
items = append(items, indicator+nameStyle.Render(displayName))
content = indicator + displayName
} else {
nameWidth := 15
if innerWidth < 25 {
@@ -631,33 +656,41 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
if len(displayName) > maxNameChars {
displayName = displayName[:maxNameChars-1] + "…"
}
name := nameStyle.Width(maxNameChars).Render(displayName)
// Description gets remaining space.
// Description gets remaining space
maxDescLen := max(innerWidth-nameWidth, 0)
desc := sc.Description
if maxDescLen < 4 {
items = append(items, indicator+name)
} else {
if maxDescLen >= 4 && desc != "" {
if len(desc) > maxDescLen {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
content = indicator + lipgloss.NewStyle().Width(maxNameChars).Render(displayName) + desc
} else {
content = indicator + displayName
}
}
}
items = append(items, itemStyle.Render(content))
}
// Add scroll indicators with background
scrollStyle := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.VeryMuted).
Width(innerWidth).
Padding(0, 1)
if startIdx > 0 {
items = append([]string{lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↑ more above")}, items...)
items = append([]string{scrollStyle.Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
items = append(items, lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↓ more below"))
items = append(items, scrollStyle.Render(" ↓ more below"))
}
content := strings.Join(items, "\n")
// Adapt footer text to available width.
// Adapt footer text to available width with background
var footerText string
if innerWidth >= 50 {
footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
@@ -666,7 +699,10 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
} else {
footerText = "↑↓ tab ↵ esc"
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Italic(true).
footer := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.VeryMuted).
Italic(true).
Render(footerText)
return popupStyle.Render(content + "\n\n" + footer)
@@ -696,10 +732,10 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
s.argMode = true
s.argCommand = cmdName
s.argSynthCmds = make([]SlashCommand, len(suggestions))
s.argSynthCmds = make([]commands.SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, sug := range suggestions {
s.argSynthCmds[i] = SlashCommand{Name: sug}
s.argSynthCmds[i] = commands.SlashCommand{Name: sug}
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
}
return s.filtered
@@ -707,7 +743,7 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
// findCommandWithComplete looks up a command by name that has a non-nil
// Complete function.
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
func (s *InputComponent) findCommandWithComplete(name string) *commands.SlashCommand {
for i := range s.commands {
if s.commands[i].Name == name && s.commands[i].Complete != nil {
return &s.commands[i]
@@ -725,7 +761,7 @@ func readClipboardImageCmd() tea.Cmd {
return clipboardImageMsg{err: err}
}
return clipboardImageMsg{
image: &ImageAttachment{
image: &core.ImageAttachment{
Data: img.Data,
MediaType: img.MediaType,
},
@@ -735,7 +771,7 @@ func readClipboardImageCmd() tea.Cmd {
// ClearPendingImages removes all pending image attachments and returns them.
// Used by the parent model when consuming images for submission.
func (s *InputComponent) ClearPendingImages() []ImageAttachment {
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
images := s.pendingImages
s.pendingImages = nil
return images
+20 -37
View File
@@ -6,6 +6,9 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/render"
"github.com/mark3labs/kit/internal/ui/style"
)
// --------------------------------------------------------------------------
@@ -143,47 +146,20 @@ func (s *StreamingMessageItem) Render(width int) string {
return s.cachedRender
}
// Get renderer from context
renderer := newMessageRenderer(width, false)
var rendered string
if s.role == "reasoning" {
// Render as reasoning/thinking block with live duration counter
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
ty := createTypography(theme)
content := strings.TrimLeft(s.content, " \t\n")
var parts []string
parts = append(parts, mutedStyle.Render(ty.Italic(content)))
// Add live duration counter (updates on each render)
var duration time.Duration
// Calculate duration in milliseconds for render.ReasoningBlock
var durationMs int64
if s.finalDuration > 0 {
// Streaming complete, show frozen duration
duration = s.finalDuration
durationMs = s.finalDuration.Milliseconds()
} else if !s.startTime.IsZero() {
// Still streaming, show live duration
duration = time.Since(s.startTime)
durationMs = time.Since(s.startTime).Milliseconds()
}
if duration > 0 {
var durationStr string
if duration < time.Second {
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
parts = append(parts, label+durationStyled)
}
rendered = styleMarginBottom1.Render(strings.Join(parts, "\n"))
ty := createTypography(style.GetTheme())
rendered = render.ReasoningBlock(s.content, durationMs, ty, style.GetTheme())
} else {
// Render as assistant message
msg := renderer.RenderAssistantMessage(s.content, s.timestamp, s.modelName)
rendered = msg.Content
rendered = render.AssistantBlock(s.content, width, style.GetTheme())
}
// Cache and return (but reasoning is never cached due to live duration)
@@ -196,10 +172,17 @@ func (s *StreamingMessageItem) Render(width int) string {
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
if s.cachedRender == "" {
// For reasoning blocks, cachedRender is never populated (rendering is
// width-independent and includes a live timer). Fall back to Render(0)
// so callers always get the correct height.
rendered := s.cachedRender
if rendered == "" {
rendered = s.Render(0)
}
if rendered == "" {
return 0
}
return strings.Count(s.cachedRender, "\n") + 1
return strings.Count(rendered, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
@@ -255,7 +238,7 @@ func (m *StreamingBashOutputItem) Render(width int) string {
return m.cachedRender
}
theme := GetTheme()
theme := style.GetTheme()
var parts []string
// Header with command
+22 -51
View File
@@ -9,6 +9,9 @@ import (
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"github.com/mark3labs/kit/internal/ui/render"
"github.com/mark3labs/kit/internal/ui/style"
)
// MessageType represents different categories of messages displayed in the UI,
@@ -138,7 +141,7 @@ func newMessageRenderer(width int, debug bool) *MessageRenderer {
return &MessageRenderer{
width: width,
debug: debug,
ty: createTypography(GetTheme()),
ty: createTypography(style.GetTheme()),
}
}
@@ -149,12 +152,7 @@ func (r *MessageRenderer) SetWidth(width int) {
// RenderUserMessage renders a user's input message using herald Tip alert
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
content = "(empty message)"
}
rendered := r.ty.Tip(content)
rendered = styleMarginBottom1.Render(rendered)
rendered := render.UserBlock(content, r.ty, style.GetTheme())
return UIMessage{
Type: UserMessage,
@@ -166,18 +164,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
// RenderAssistantMessage renders an AI assistant's response
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
if strings.TrimSpace(content) == "" {
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
// Use markdown rendering with Chroma syntax highlighting
rendered := toMarkdown(content, r.width-4)
rendered = styleMarginBottom1.Render(rendered)
rendered := render.AssistantBlock(content, r.width, style.GetTheme())
return UIMessage{
Type: AssistantMessage,
@@ -191,23 +178,7 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
// as live streaming: muted italic text with margin. This is used when resuming
// sessions to display saved reasoning content.
func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
theme := GetTheme()
// Match live streaming styling: muted italic text
// Same as stream.go renderReasoningBlock()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
rendered := mutedStyle.Render(r.ty.Italic(contentStr))
rendered = styleMarginBottom1.Render(rendered)
rendered := render.ReasoningBlock(content, 0, r.ty, style.GetTheme())
return UIMessage{
Type: AssistantMessage,
@@ -219,12 +190,7 @@ func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Ti
// RenderSystemMessage renders KIT system messages using herald Note alert
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
content = "No content available"
}
rendered := r.ty.Note(content)
rendered = styleMarginBottom1.Render(rendered)
rendered := render.SystemBlock(content, r.ty, style.GetTheme())
return UIMessage{
Type: SystemMessage,
@@ -290,8 +256,7 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
// RenderErrorMessage renders error notifications
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
rendered := r.ty.Caution(errorMsg)
rendered = styleMarginBottom1.Render(rendered)
rendered := render.ErrorBlock(errorMsg, r.ty, style.GetTheme())
return UIMessage{
Type: ErrorMessage,
@@ -323,16 +288,16 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
}
var icon string
iconColor := GetTheme().Success
iconColor := style.GetTheme().Success
if isError {
icon = "×"
iconColor = GetTheme().Error
iconColor = style.GetTheme().Error
} else {
icon = "✓"
}
// Style the tool name with color
theme := GetTheme()
theme := style.GetTheme()
nameColor := theme.Info
if isError {
nameColor = theme.Error
@@ -351,7 +316,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-8)
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
body = strings.TrimSuffix(style.ToMarkdown(body, r.width-8), "\n")
}
}
if body == "" {
@@ -369,6 +334,12 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
body = r.ty.Italic("(no output)")
}
// Wrap all tool errors in a herald Caution alert so the error text
// renders inside a contained block instead of spilling into the layout.
if isError && strings.TrimSpace(body) != "" {
body = r.ty.Alert(herald.AlertCaution, body)
}
// Compose: icon + name + params, then body
fullContent := r.ty.Compose(
headerLine,
@@ -397,7 +368,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string {
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") ||
strings.Contains(toolName, "shell") {
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
return parseBashOutput(result, GetTheme())
return parseBashOutput(result, style.GetTheme())
}
}
@@ -405,7 +376,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string {
}
// createTypography creates a typography instance from theme
func createTypography(theme Theme) *herald.Typography {
func createTypography(theme style.Theme) *herald.Typography {
return herald.New(
herald.WithPalette(herald.ColorPalette{
Primary: theme.Primary,
@@ -437,5 +408,5 @@ func createTypography(theme Theme) *herald.Typography {
// UpdateTheme refreshes the renderer's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (r *MessageRenderer) UpdateTheme() {
r.ty = createTypography(GetTheme())
r.ty = createTypography(style.GetTheme())
}
+235 -134
View File
@@ -19,6 +19,12 @@ import (
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/prompts"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/ui/clipboard"
"github.com/mark3labs/kit/internal/ui/commands"
uicore "github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/fileutil"
"github.com/mark3labs/kit/internal/ui/prefs"
"github.com/mark3labs/kit/internal/ui/style"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -112,6 +118,10 @@ type AppController interface {
// message starts executing immediately. Returns 0 if started
// immediately, >0 if injected/pending.
Steer(prompt string) int
// SteerWithFiles injects a steering message with optional file
// attachments (e.g. pasted images) into the currently running agent
// turn. Behaves like Steer but includes file parts alongside the text.
SteerWithFiles(prompt string, files []kit.LLMFilePart) int
}
// SkillItem holds display metadata about a loaded skill for the startup
@@ -276,7 +286,7 @@ type AppModelOptions struct {
// ExtensionCommands are slash commands registered by extensions. They
// appear in autocomplete, /help, and are dispatched when submitted.
ExtensionCommands []ExtensionCommand
ExtensionCommands []commands.ExtensionCommand
// PromptTemplates are user-defined prompt templates loaded from ~/.kit/prompts/,
// .kit/prompts/, or explicit --prompt-template paths. They appear in autocomplete
@@ -355,7 +365,7 @@ type AppModelOptions struct {
// GetExtensionCommands, if non-nil, returns the current extension
// commands. Called on WidgetUpdateEvent to refresh the command list
// after an extension hot-reload. May be nil if no extensions loaded.
GetExtensionCommands func() []ExtensionCommand
GetExtensionCommands func() []commands.ExtensionCommand
// SetModel changes the active model at runtime. The model string uses
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
@@ -382,6 +392,11 @@ type AppModelOptions struct {
// initialization. They are displayed in the ScrollList at startup.
StartupExtensionMessages []string
// ReloadExtensions hot-reloads all extensions from disk. Called by
// the /reload-ext command and the automatic file watcher. May be nil
// if no extensions are loaded.
ReloadExtensions func() error
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
ThinkingLevel string
// IsReasoningModel is true when the current model supports reasoning.
@@ -478,7 +493,7 @@ type AppModel struct {
// extensionCommands are slash commands from extensions, dispatched via
// handleExtensionCommand when submitted.
extensionCommands []ExtensionCommand
extensionCommands []commands.ExtensionCommand
// promptTemplates are user-defined prompt templates for expansion.
// They appear in autocomplete and are expanded when submitted.
@@ -542,7 +557,7 @@ type AppModel struct {
// getExtensionCommands returns the current extension commands. Used
// to refresh the command list after an extension hot-reload. May be nil.
getExtensionCommands func() []ExtensionCommand
getExtensionCommands func() []commands.ExtensionCommand
// setModel changes the active model at runtime. Wired from cmd/root.go.
// May be nil if model switching is not supported.
@@ -557,6 +572,9 @@ type AppModel struct {
// sessionSelector is the session picker overlay, active in stateSessionSelector.
sessionSelector *SessionSelectorComponent
// reloadExtensions hot-reloads all extensions from disk. May be nil.
reloadExtensions func() error
// switchSession opens a session by JSONL path, replacing the active session.
// Wired from cmd/root.go.
switchSession func(path string) error
@@ -622,6 +640,11 @@ type AppModel struct {
// recalculation. Set when loading a session so that scrolling to the
// bottom happens with the correct viewport height.
pendingGotoBottom bool
// scrollbackYOffset is the Y coordinate where the scrollback area starts
// on screen (after header). Mouse Y coordinates must be adjusted by this
// offset before being passed to the ScrollList.
scrollbackYOffset int
}
// --------------------------------------------------------------------------
@@ -710,10 +733,14 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.setModel = opts.SetModel
m.emitModelChange = opts.EmitModelChange
m.thinkingLevel = opts.ThinkingLevel
// Initialize the theme list function for command completion.
commands.ListThemesFunc = style.ListThemes
m.thinkingVisible = true // default to showing thinking blocks
m.isReasoningModel = opts.IsReasoningModel
m.setThinkingLevel = opts.SetThinkingLevel
m.switchSession = opts.SwitchSession
m.reloadExtensions = opts.ReloadExtensions
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -741,7 +768,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Merge extension commands into the InputComponent's autocomplete source.
if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 {
for _, ec := range opts.ExtensionCommands {
ic.commands = append(ic.commands, SlashCommand{
ic.commands = append(ic.commands, commands.SlashCommand{
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
@@ -753,7 +780,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Merge prompt templates into the InputComponent's autocomplete source.
if ic, ok := m.input.(*InputComponent); ok && len(opts.PromptTemplates) > 0 {
for _, tpl := range opts.PromptTemplates {
ic.commands = append(ic.commands, SlashCommand{
ic.commands = append(ic.commands, commands.SlashCommand{
Name: "/" + tpl.Name,
Description: tpl.Description,
Category: "Prompts",
@@ -810,12 +837,12 @@ func (m *AppModel) AddStartupMessageToScrollList() {
}
// Add the ASCII logo at the very top.
logo := KitBanner()
logo := style.KitBanner()
logoMsg := NewStyledMessageItem(generateMessageID(), "logo", logo, logo)
m.messages = append(m.messages, logoMsg)
// Build key-value pairs for startup info.
ty := createTypography(GetTheme())
ty := createTypography(style.GetTheme())
var pairs [][2]string
if m.providerName != "" && m.modelName != "" {
@@ -873,7 +900,7 @@ func (m *AppModel) AddStartupMessageToScrollList() {
// Add a visual separator after startup info: blank line + HR + blank line.
// Uses a single pre-rendered item so there are no left borders on the spacing.
theme := GetTheme()
theme := style.GetTheme()
separator := strings.Repeat("─", 80)
separatorStyled := lipgloss.NewStyle().
Foreground(theme.Border).
@@ -916,7 +943,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// ── Tree selector events ─────────────────────────────────────────────────
case TreeNodeSelectedMsg:
case uicore.TreeNodeSelectedMsg:
// User selected a node in the tree. Branch to it and return to input.
if ts := m.appCtrl.GetTreeSession(); ts != nil {
// For user messages: branch to parent (so user can resubmit).
@@ -961,7 +988,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
return m, tea.Batch(cmds...)
case TreeCancelledMsg:
case uicore.TreeCancelledMsg:
m.treeSelector = nil
m.state = stateInput
return m, nil
@@ -985,7 +1012,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(msg.ModelString) }()
go func() { _ = prefs.SaveModelPreference(msg.ModelString) }()
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
@@ -1061,48 +1088,48 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// ── Mouse click selection ─────────────────────────────────────────────────
// DISABLED: Selection/copy functionality is disabled for now but plumbing remains
// case tea.MouseClickMsg:
// // Handle mouse clicks in the scrollback area for item selection (crush-style)
// // Only process left clicks in input state
// if m.state == stateInput && msg.Button == tea.MouseLeft {
// // Enable selection on the scrollList
// m.scrollList.SetSelectable(true)
// // Handle mouse down for selection tracking
// if m.scrollList.HandleMouseDown(msg.X, msg.Y) {
// // Disable auto-scroll so user can read
// m.scrollList.autoScroll = false
// }
// }
// ── Mouse click selection (crush-style character-level) ──────────────────
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
// Calculate viewport-relative coordinates.
viewY := msg.Y - m.scrollbackYOffset
if viewY >= 0 && viewY < m.scrollList.height {
// Clear any previous selection on a new click.
// HandleMouseDown will set up new selection state.
if m.scrollList.HandleMouseDown(msg.X, viewY) {
m.scrollList.autoScroll = false
}
}
}
// ── Mouse motion/drag for selection ──────────────────────────────────────
// DISABLED: Selection/copy functionality is disabled for now but plumbing remains
// case tea.MouseMotionMsg:
// // Handle mouse motion for text selection (crush-style)
// // MouseMotionMsg is sent when mouse moves while button is held
// if m.state == stateInput {
// m.scrollList.HandleMouseDrag(msg.X, msg.Y)
// }
// ── Mouse motion/drag for character-level selection ──────────────────────
case tea.MouseMotionMsg:
viewY := msg.Y - m.scrollbackYOffset
if viewY >= 0 && viewY < m.scrollList.height {
m.scrollList.HandleMouseDrag(msg.X, viewY)
}
// ── Mouse release for copy ───────────────────────────────────────────────
// DISABLED: Selection/copy functionality is disabled for now but plumbing remains
// case tea.MouseReleaseMsg:
// // Handle mouse release to finalize selection and copy (crush-style)
// if m.state == stateInput {
// if m.scrollList.HandleMouseUp(msg.X, msg.Y) {
// // Selection was made - copy to clipboard
// if m.scrollList.HasSelection() {
// // Get selected content and copy
// // For now, copy a placeholder - full implementation would extract text
// cmd := CopyToClipboardWithMessage("Selected text", "Selection copied to clipboard")
// cmds = append(cmds, cmd)
// }
// }
// }
// ── Mouse release: finalize selection and copy to clipboard ──────────────
case tea.MouseReleaseMsg:
if m.scrollList.HandleMouseUp() {
// Selection completed — extract text and copy to clipboard.
if m.scrollList.HasSelection() {
text := m.scrollList.ExtractSelectedText()
if text != "" {
cmd := clipboard.CopyToClipboard(text)
cmds = append(cmds, cmd)
}
// Clear selection after copy (crush-style: copy on mouse-up).
m.scrollList.ClearSelection()
}
}
// ── Keyboard input ───────────────────────────────────────────────────────
case tea.KeyPressMsg:
// Clear any active mouse selection on keypress.
if m.scrollList.HasSelection() {
m.scrollList.ClearSelection()
}
switch msg.String() {
case "ctrl+c":
// Cancel any active prompt before quitting.
@@ -1142,24 +1169,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Only active when not working (to avoid conflicts during streaming).
if m.state == stateInput {
switch msg.String() {
// DISABLED: Copy shortcuts disabled for now but plumbing remains
// case "c", "y":
// // Copy current focused message or selection to clipboard (crush-style)
// if m.scrollList.HasSelection() {
// // Copy selection
// cmd := CopyToClipboardWithMessage("Selected text", "Selection copied to clipboard")
// cmds = append(cmds, cmd)
// } else if m.scrollList.FocusedIdx() >= 0 {
// // Copy focused message content
// idx := m.scrollList.FocusedIdx()
// if idx < len(m.messages) {
// // Get the message content - would need to extract raw text
// // For now, use a placeholder
// cmd := CopyToClipboardWithMessage("Message content", "Message copied to clipboard")
// cmds = append(cmds, cmd)
// }
// }
// return m, tea.Batch(cmds...)
case "pgup":
m.scrollList.ScrollBy(-m.scrollList.height)
m.scrollList.autoScroll = false
@@ -1250,26 +1259,43 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text = strings.TrimSpace(ic.textarea.Value())
}
if text != "" {
// Clear the input and push to history.
// Clear the input, collect pending images, and push to history.
var images []uicore.ImageAttachment
if ic, ok := m.input.(*InputComponent); ok {
ic.pushHistory(text)
ic.textarea.SetValue("")
images = ic.ClearPendingImages()
}
// Preprocess @file references.
processedText := text
if m.cwd != "" {
processedText = ProcessFileAttachments(text, m.cwd)
processedText = fileutil.ProcessFileAttachments(text, m.cwd)
}
// Convert image attachments to kit.LLMFilePart for the app layer.
var fileParts []kit.LLMFilePart
for _, img := range images {
fileParts = append(fileParts, kit.LLMFilePart{
Data: img.Data,
MediaType: img.MediaType,
})
}
// Build display text (include image count if any).
displayText := text
if len(images) > 0 {
displayText = fmt.Sprintf("%s\n[%d image(s) attached]", text, len(images))
}
// Inject the steer message.
sLen := m.appCtrl.Steer(processedText)
sLen := m.appCtrl.SteerWithFiles(processedText, fileParts)
if sLen > 0 {
m.steeringMessages = append(m.steeringMessages, text)
m.steeringMessages = append(m.steeringMessages, displayText)
m.layoutDirty = true
} else {
// Started immediately (agent was idle).
m.pendingUserPrints = append(m.pendingUserPrints, text)
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
if m.state != stateWorking {
m.state = stateWorking
@@ -1304,7 +1330,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If remap target is unrecognized, fall through to normal handling.
case EditorKeySubmit:
text := action.SubmitText
var images []ImageAttachment
var images []uicore.ImageAttachment
if text == "" {
if ic, ok := m.input.(*InputComponent); ok {
text = strings.TrimSpace(ic.textarea.Value())
@@ -1315,7 +1341,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if text != "" {
cmds = append(cmds, func() tea.Msg {
return submitMsg{Text: text, Images: images}
return uicore.SubmitMsg{Text: text, Images: images}
})
}
intercepted = true
@@ -1331,18 +1357,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// ── Cancel timer expired ─────────────────────────────────────────────────
case cancelTimerExpiredMsg:
case uicore.CancelTimerExpiredMsg:
m.canceling = false
// ── Input submitted ──────────────────────────────────────────────────────
case submitMsg:
case uicore.SubmitMsg:
// Re-enable auto-scroll when user submits a new message.
m.scrollList.autoScroll = true
// Handle slash commands locally — they should never reach app.Run().
// Parse once: split on the first space so argument-bearing commands
// (e.g. "/model anthropic/foo", "/compact Focus on X") are matched by
// their name and their args are passed through to the handler.
if strings.HasPrefix(msg.Text, "/") {
name, args, _ := strings.Cut(msg.Text, " ")
if sc := GetCommandByName(name); sc != nil {
if sc := commands.GetCommandByName(name); sc != nil {
if cmd := m.handleSlashCommand(sc, strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -1369,7 +1398,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ScrollList) uses the original user text so the UI stays clean.
processedText := msg.Text
if m.cwd != "" {
processedText = ProcessFileAttachments(msg.Text, m.cwd)
processedText = fileutil.ProcessFileAttachments(msg.Text, m.cwd)
}
// Convert image attachments to kit.LLMFilePart for the app layer.
@@ -1420,7 +1449,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// ── Shell command (! / !!) ───────────────────────────────────────────────
case shellCommandMsg:
case uicore.ShellCommandMsg:
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
@@ -1431,7 +1460,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Execute the shell command asynchronously so the TUI stays responsive.
cmds = append(cmds, m.executeShellCommand(msg))
case shellCommandResultMsg:
case uicore.ShellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
@@ -1472,6 +1501,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Also update/create StreamingMessageItem in ScrollList for live display
m.appendStreamingChunk("reasoning", msg.Delta)
case app.ReasoningCompleteEvent:
// Forward to stream component to freeze reasoning duration
if m.stream != nil {
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
// Mark the reasoning StreamingMessageItem as complete to freeze its counter
if len(m.messages) > 0 {
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok && streamMsg.role == "reasoning" {
streamMsg.MarkComplete()
}
}
case app.StreamChunkEvent:
// Forward to stream component for display rendering
if m.stream != nil {
@@ -1735,14 +1779,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.extensionCommands = newCmds
if ic, ok := m.input.(*InputComponent); ok {
// Remove old extension commands and add fresh ones.
var builtins []SlashCommand
var builtins []commands.SlashCommand
for _, sc := range ic.commands {
if sc.Category != "Extensions" {
builtins = append(builtins, sc)
}
}
for _, ec := range newCmds {
builtins = append(builtins, SlashCommand{
builtins = append(builtins, commands.SlashCommand{
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
@@ -1827,6 +1871,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.printSystemMessage(msg.output)
}
case extReloadResultMsg:
if msg.err != nil {
m.printSystemMessage(fmt.Sprintf("Extension reload failed: %v", msg.err))
} else {
m.printSystemMessage("Extensions reloaded.")
}
case beforeSessionSwitchResultMsg:
// Async before-session-switch hook completed. Proceed with the
// session reset if the hook did not cancel.
@@ -1905,10 +1956,7 @@ func (m *AppModel) View() tea.View {
return m.treeSelector.View()
}
// Model selector overlay replaces the normal layout.
if m.state == stateModelSelector && m.modelSelector != nil {
return m.modelSelector.View()
}
// Model selector is rendered as a centered overlay later (see below).
// Session selector overlay replaces the normal layout.
if m.state == stateSessionSelector && m.sessionSelector != nil {
@@ -1967,8 +2015,11 @@ func (m *AppModel) View() tea.View {
var parts []string
// Custom header (if set by extension) — above everything.
// Track its height so mouse coordinates can be adjusted for the scrollback.
m.scrollbackYOffset = 0
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
parts = append(parts, headerView)
m.scrollbackYOffset = lipgloss.Height(headerView)
}
// Only include the scrollback region when it has content. When idle the
@@ -1980,7 +2031,7 @@ func (m *AppModel) View() tea.View {
// Add canceling warning between scrollback and separator
// (doesn't go inside scrollback viewport to avoid affecting scroll position)
theme := GetTheme()
theme := style.GetTheme()
if m.canceling {
warning := lipgloss.NewStyle().
Foreground(theme.Warning).
@@ -2029,6 +2080,12 @@ func (m *AppModel) View() tea.View {
}
}
// Render model selector as centered overlay if active
if m.state == stateModelSelector && m.modelSelector != nil {
popupContent := m.modelSelector.RenderOverlay(m.width, m.height)
finalContent = overlayContent(finalContent, popupContent, m.width, m.height)
}
v := tea.NewView(finalContent)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
@@ -2098,7 +2155,7 @@ func (m *AppModel) renderScrollback() string {
// This bar is always present so its height is constant, eliminating layout
// shifts from spinner or usage info appearing/disappearing.
func (m *AppModel) renderStatusBar() string {
theme := GetTheme()
theme := style.GetTheme()
// Left side: spinner animation (when active).
var leftSide string
@@ -2207,12 +2264,12 @@ func (m *AppModel) cycleThinkingLevel() {
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(next) }()
go func() { _ = prefs.SaveThinkingLevelPreference(next) }()
}
// renderSeparator renders the separator line with an optional queue/steer count badge.
func (m *AppModel) renderSeparator() string {
theme := GetTheme()
theme := style.GetTheme()
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
queueLen := len(m.queuedMessages)
steerLen := len(m.steeringMessages)
@@ -2267,7 +2324,7 @@ func (m *AppModel) renderWidgetSlot(placement string) string {
return ""
}
theme := GetTheme()
theme := style.GetTheme()
var blocks []string
for _, w := range widgets {
content := w.Text
@@ -2307,7 +2364,7 @@ func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string {
return ""
}
theme := GetTheme()
theme := style.GetTheme()
var opts []renderingOption
opts = append(opts, WithAlign(lipgloss.Left))
@@ -2335,13 +2392,13 @@ func (m *AppModel) renderQueuedMessages() string {
if len(m.queuedMessages) == 0 && len(m.steeringMessages) == 0 {
return ""
}
theme := GetTheme()
theme := style.GetTheme()
var blocks []string
// Render steering messages first (higher priority).
if len(m.steeringMessages) > 0 {
badge := CreateBadge("STEERING", theme.Warning)
badge := style.CreateBadge("STEERING", theme.Warning)
for _, msg := range m.steeringMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
@@ -2356,7 +2413,7 @@ func (m *AppModel) renderQueuedMessages() string {
// Render queued messages.
if len(m.queuedMessages) > 0 {
badge := CreateBadge("QUEUED", theme.Accent)
badge := style.CreateBadge("QUEUED", theme.Accent)
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
@@ -2447,7 +2504,7 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd.
// args contains any text after the command name (may be empty).
func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) tea.Cmd {
switch sc.Name {
case "/quit":
m.quitting = true
@@ -2470,6 +2527,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
return m.handleThinkingCommand(args)
case "/compact":
return m.handleCompactCommand(args)
case "/reload-ext":
return m.handleReloadExtCommand()
case "/clear":
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
@@ -2526,7 +2585,7 @@ func (m *AppModel) printSystemMessage(text string) {
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the ScrollList.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
theme := style.GetTheme()
// Resolve border color: use the extension's hex value, fall back to theme info.
borderClr := theme.Info
@@ -2580,7 +2639,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
// Split: "/sub list files" → name="/sub", args="list files"
name, args, _ := strings.Cut(text, " ")
ecmd := FindExtensionCommand(name, m.extensionCommands)
ecmd := commands.FindExtensionCommand(name, m.extensionCommands)
if ecmd == nil {
return nil
}
@@ -2755,6 +2814,22 @@ func (m *AppModel) printResetUsage() {
// the app controller rejects the request (busy, closed) it prints an error
// instead. customInstructions is optional text appended to the summary
// prompt (e.g. "Focus on the API design decisions").
// handleReloadExtCommand reloads all extensions from disk asynchronously.
// It returns a tea.Cmd to avoid calling prog.Send() from inside Update()
// which would deadlock if any extension handler calls ctx.Print() during
// SessionShutdown or SessionStart events.
func (m *AppModel) handleReloadExtCommand() tea.Cmd {
if m.reloadExtensions == nil {
m.printSystemMessage("No extensions loaded.")
return nil
}
reload := m.reloadExtensions
return func() tea.Msg {
err := reload()
return extReloadResultMsg{err: err}
}
}
func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
if m.appCtrl == nil {
m.printSystemMessage("Compaction is not available.")
@@ -2848,8 +2923,15 @@ func (m *AppModel) appendStreamingChunk(role, content string) {
streamMsg.AppendChunk(content)
// Auto-scroll to bottom if enabled (iteratr pattern)
// Don't call SetItems() - the slice reference hasn't changed
if m.scrollList != nil && m.scrollList.autoScroll {
m.scrollList.GotoBottom()
if m.scrollList != nil {
if m.scrollList.autoScroll {
m.scrollList.GotoBottom()
} else if m.scrollList.AtBottom() {
// User manually scrolled back to bottom during streaming,
// re-enable auto-scroll so they follow new content
m.scrollList.autoScroll = true
m.scrollList.GotoBottom()
}
}
return
}
@@ -3062,7 +3144,7 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
}
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(args) }()
go func() { _ = prefs.SaveModelPreference(args) }()
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
@@ -3078,8 +3160,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
if args == "" {
// List available themes.
names := ListThemes()
active := ActiveThemeName()
names := style.ListThemes()
active := style.ActiveThemeName()
var lines []string
lines = append(lines, "Available themes:")
@@ -3091,8 +3173,8 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
}
}
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("User themes: %s", userThemesDir()))
if pdir := projectThemesDir(); pdir != "" {
lines = append(lines, fmt.Sprintf("User themes: %s", style.UserThemesDir()))
if pdir := style.ProjectThemesDir(); pdir != "" {
lines = append(lines, fmt.Sprintf("Project themes: %s", pdir))
} else {
lines = append(lines, "Project themes: .kit/themes/ (not found)")
@@ -3101,7 +3183,7 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
return nil
}
if err := ApplyTheme(args); err != nil {
if err := style.ApplyTheme(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Theme error: %v", err))
return nil
}
@@ -3156,7 +3238,7 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
}()
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(string(level)) }()
go func() { _ = prefs.SaveThinkingLevelPreference(string(level)) }()
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
@@ -3184,6 +3266,8 @@ func (m *AppModel) handleTreeCommand() tea.Cmd {
// handleForkCommand creates a branch from the current position. Like /tree
// but opens the selector directly for fork semantics.
// Unlike /tree which shows the full tree, /fork shows only user messages
// (matching Pi's behavior) and creates a new session file when a message is selected.
func (m *AppModel) handleForkCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3195,7 +3279,8 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
// Use the fork-specific selector that shows only user messages.
m.treeSelector = NewTreeSelectorForFork(ts, m.width, m.height)
m.state = stateTreeSelector
return nil
}
@@ -3262,8 +3347,11 @@ func (m *AppModel) performNewSession() tea.Cmd {
return nil
}
// performFork performs the actual tree branch. Called either directly (when no
// before-hook exists) or after the async before-fork hook completes.
// performFork creates a new session by forking from the target entry.
// This matches Pi's /fork behavior: it creates a completely new session file
// with the history up to the target point, then switches to that session.
// Called either directly (when no before-hook exists) or after the async
// before-fork hook completes.
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3271,12 +3359,25 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
return nil
}
// Branch the tree session to the target entry. We must NOT call
// ClearMessages() here because it resets the leaf pointer back to "",
// undoing the branch we just set. Instead, branch first and then
// reload the in-memory store from the tree session's current branch.
_ = ts.Branch(targetID)
m.appCtrl.ReloadMessagesFromTree()
// Create a new session by forking from the target entry.
// This creates a new session file with the history up to the target point.
newTs, err := ts.ForkToNewSession(m.cwd, targetID)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to fork session: %v", err))
return nil
}
// Switch to the new forked session.
m.appCtrl.SwitchTreeSession(newTs)
// Reset usage statistics for the new session.
if m.usageTracker != nil {
m.usageTracker.Reset()
}
// Clear the scroll list and populate all messages from the forked history.
m.messages = []MessageItem{}
m.renderSessionHistory()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
@@ -3286,14 +3387,7 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
}
m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
return "Edit and resubmit to create a new branch."
}
return "Continue from this point."
}()))
m.printSystemMessage("Forked to new session. Edit and resubmit to continue.")
return nil
}
@@ -3562,7 +3656,7 @@ func (m *AppModel) renderSessionHistory() {
switch msg.Role {
case message.RoleUser:
text := msg.Content()
text := strings.TrimSpace(msg.Content())
if text != "" {
styledMsg := m.renderer.RenderUserMessage(text, msg.CreatedAt)
item := NewStyledMessageItem(generateMessageID(), "user", text, styledMsg.Content)
@@ -3578,7 +3672,7 @@ func (m *AppModel) renderSessionHistory() {
m.messages = append(m.messages, item)
}
// Then render the text content
text := msg.Content()
text := strings.TrimSpace(msg.Content())
if text != "" {
modelName := m.modelName
if msg.Model != "" {
@@ -3654,11 +3748,11 @@ func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
// Cancel timer command
// --------------------------------------------------------------------------
// cancelTimerCmd returns a tea.Cmd that fires cancelTimerExpiredMsg after 2s.
// cancelTimerCmd returns a tea.Cmd that fires CancelTimerExpiredMsg after 2s.
// This is used for the double-tap ESC cancel flow.
func cancelTimerCmd() tea.Cmd {
return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg {
return cancelTimerExpiredMsg{}
return uicore.CancelTimerExpiredMsg{}
})
}
@@ -3673,6 +3767,13 @@ type shareResultMsg struct {
viewerURL string
}
// extReloadResultMsg carries the result of an asynchronously executed
// /reload-ext command. The reload runs async to avoid deadlocking the
// TUI event loop (extension handlers may call prog.Send via ctx.Print).
type extReloadResultMsg struct {
err error
}
// extensionCmdResultMsg carries the result of an asynchronously executed
// extension slash command. Extension commands run async (via tea.Cmd) so they
// can safely call blocking operations like ctx.PromptSelect().
@@ -3842,9 +3943,9 @@ func (m *AppModel) resolveOverlay(resp app.OverlayResponse) {
const shellCommandTimeout = 120 * time.Second
// executeShellCommand runs a shell command asynchronously and returns the
// result as a shellCommandResultMsg. This is launched from Update() as a
// result as a ShellCommandResultMsg. This is launched from Update() as a
// tea.Cmd so the TUI stays responsive during execution.
func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
func (m *AppModel) executeShellCommand(msg uicore.ShellCommandMsg) tea.Cmd {
command := msg.Command
excludeFromContext := msg.ExcludeFromContext
cwd := m.cwd
@@ -3879,7 +3980,7 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
// Non-zero exit is reported via exitCode, not as an error.
err = nil
} else if ctx.Err() == context.DeadlineExceeded {
return shellCommandResultMsg{
return uicore.ShellCommandResultMsg{
Command: command,
Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout),
ExitCode: -1,
@@ -3901,7 +4002,7 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
combined.WriteString(stderr.String())
}
return shellCommandResultMsg{
return uicore.ShellCommandResultMsg{
Command: command,
Output: combined.String(),
ExitCode: exitCode,
@@ -3914,8 +4015,8 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
// handleShellCommandResult processes the result of a shell command execution.
// It prints the output to the ScrollList and optionally injects it into the
// conversation context (for ! commands) so the LLM can see it.
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
theme := GetTheme()
func (m *AppModel) handleShellCommandResult(msg uicore.ShellCommandResultMsg) tea.Cmd {
theme := style.GetTheme()
// Build the display header.
var header string
+99 -285
View File
@@ -5,9 +5,7 @@ import (
"sort"
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/models"
)
@@ -29,16 +27,14 @@ type ModelSelectedMsg struct {
// ModelSelectorCancelledMsg is sent when the user cancels the selector.
type ModelSelectorCancelledMsg struct{}
// ModelSelectorComponent is a full-screen Bubble Tea component that displays
// a filterable list of available models. It follows the same pattern as
// TreeSelectorComponent: inline text search, scrolling list, and custom
// messages for result delivery.
// ModelSelectorComponent is a Bubble Tea component that displays a filterable
// list of available models as a centered overlay popup. It delegates rendering
// and keyboard navigation to PopupList and converts results into the
// ModelSelectedMsg / ModelSelectorCancelledMsg messages expected by AppModel.
type ModelSelectorComponent struct {
allModels []ModelEntry // all available models (pre-sorted)
filtered []ModelEntry // subset matching the current search
cursor int
search string
currentModel string // "provider/model" of the active model (for checkmark)
popup *PopupList
allModels []ModelEntry // kept for the custom filter callback
currentModel string // "provider/model" of the active model
width int
height int
active bool
@@ -61,7 +57,22 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp
continue
}
// For the custom provider, skip the built-in "custom" stub when
// user-defined models are present — the stub is a fallback for
// --provider-url usage and would just clutter the list.
userDefinedCustomModels := 0
if providerID == "custom" {
for modelID := range modelsMap {
if modelID != "custom" {
userDefinedCustomModels++
}
}
}
for modelID, info := range modelsMap {
if providerID == "custom" && modelID == "custom" && userDefinedCustomModels > 0 {
continue
}
allModels = append(allModels, ModelEntry{
Provider: providerID,
ModelID: modelID,
@@ -80,24 +91,31 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp
return allModels[i].ModelID < allModels[j].ModelID
})
ms := &ModelSelectorComponent{
// Build PopupItems from model entries.
items := make([]PopupItem, len(allModels))
for i, m := range allModels {
items[i] = PopupItem{
Label: m.ModelID,
Description: fmt.Sprintf("[%s]", m.Provider),
Active: m.Provider+"/"+m.ModelID == currentModel,
Meta: m,
}
}
popup := NewPopupList("Model Selector", items, width, height)
popup.Subtitle = "Only showing models with configured API keys"
popup.FilterFunc = func(query string, allItems []PopupItem) []PopupItem {
return filterModels(query, allItems)
}
return &ModelSelectorComponent{
popup: popup,
allModels: allModels,
filtered: allModels,
currentModel: currentModel,
width: width,
height: height,
active: true,
}
// Position cursor on the current model if found.
for i, m := range ms.filtered {
if m.Provider+"/"+m.ModelID == currentModel {
ms.cursor = i
break
}
}
return ms
}
// Init implements tea.Model.
@@ -111,177 +129,43 @@ func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
ms.width = msg.Width
ms.height = msg.Height
ms.popup.SetSize(msg.Width, msg.Height)
return ms, nil
case tea.KeyPressMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
if ms.cursor > 0 {
ms.cursor--
}
result := ms.popup.HandleKey(msg.String(), msg.Text)
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
if ms.cursor < len(ms.filtered)-1 {
ms.cursor++
if result.Selected != nil {
ms.active = false
entry := result.Selected.Meta.(ModelEntry)
modelStr := entry.Provider + "/" + entry.ModelID
return ms, func() tea.Msg {
return ModelSelectedMsg{ModelString: modelStr}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
ms.cursor -= ms.visibleHeight()
if ms.cursor < 0 {
ms.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
ms.cursor += ms.visibleHeight()
if ms.cursor >= len(ms.filtered) {
ms.cursor = len(ms.filtered) - 1
}
if ms.cursor < 0 {
ms.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
ms.cursor = 0
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
ms.cursor = max(len(ms.filtered)-1, 0)
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if ms.cursor < len(ms.filtered) {
entry := ms.filtered[ms.cursor]
ms.active = false
return ms, func() tea.Msg {
return ModelSelectedMsg{
ModelString: entry.Provider + "/" + entry.ModelID,
}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if ms.search != "" {
ms.search = ""
ms.rebuildFiltered()
} else {
ms.active = false
return ms, func() tea.Msg {
return ModelSelectorCancelledMsg{}
}
}
default:
// Inline text search.
if msg.Text != "" && len(msg.Text) == 1 {
ch := msg.Text[0]
if ch >= 32 && ch < 127 {
ms.search += string(ch)
ms.rebuildFiltered()
}
}
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ms.search) > 0 {
ms.search = ms.search[:len(ms.search)-1]
ms.rebuildFiltered()
}
if result.Cancelled {
ms.active = false
return ms, func() tea.Msg {
return ModelSelectorCancelledMsg{}
}
}
}
return ms, nil
}
// View implements tea.Model.
// View implements tea.Model — not used for overlay rendering.
// Use RenderOverlay for the centered overlay approach.
func (ms *ModelSelectorComponent) View() tea.View {
theme := GetTheme()
// Fallback full-screen rendering (unused when rendered as overlay).
v := tea.NewView(ms.popup.RenderCentered(ms.width, ms.height))
v.AltScreen = true
return v
}
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(theme.Accent).
PaddingLeft(2)
helpStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
PaddingLeft(2)
infoStyle := lipgloss.NewStyle().
Foreground(theme.Warning).
PaddingLeft(2)
var b strings.Builder
// Header.
b.WriteString(headerStyle.Render("Model Selector"))
b.WriteString("\n")
// Adapt help text to terminal width.
if ms.width >= 56 {
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
} else if ms.width >= 35 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc type"))
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc"))
}
b.WriteString("\n")
if ms.width >= 48 {
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
} else {
b.WriteString(infoStyle.Render("Models with API keys"))
}
b.WriteString("\n")
// Search input.
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(2)
if ms.search != "" {
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ms.search)))
} else {
b.WriteString(searchStyle.Render("> "))
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
b.WriteString("\n")
if len(ms.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
if ms.search != "" {
b.WriteString(emptyStyle.Render("No models matching \"" + ms.search + "\""))
} else {
b.WriteString(emptyStyle.Render("No models available (check API keys)"))
}
b.WriteString("\n")
} else {
// Visible window.
visH := ms.visibleHeight()
startIdx := 0
if ms.cursor >= visH {
startIdx = ms.cursor - visH + 1
}
endIdx := min(startIdx+visH, len(ms.filtered))
for i := startIdx; i < endIdx; i++ {
entry := ms.filtered[i]
line := ms.renderEntry(entry, i == ms.cursor)
b.WriteString(line)
b.WriteString("\n")
}
}
// Footer.
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
b.WriteString("\n")
footerParts := []string{
fmt.Sprintf("(%d/%d)", ms.cursor+1, len(ms.filtered)),
}
if ms.cursor < len(ms.filtered) {
entry := ms.filtered[ms.cursor]
if entry.Name != "" {
footerParts = append(footerParts, fmt.Sprintf("Model Name: %s", entry.Name))
}
if entry.ContextLimit > 0 {
footerParts = append(footerParts, fmt.Sprintf("Context: %dK", entry.ContextLimit/1000))
}
}
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
return tea.NewView(b.String())
// RenderOverlay returns the popup as a centered overlay string, ready to be
// composited on top of the main content via overlayContent().
func (ms *ModelSelectorComponent) RenderOverlay(termWidth, termHeight int) string {
return ms.popup.RenderCentered(termWidth, termHeight)
}
// IsActive returns whether the selector is still accepting input.
@@ -289,56 +173,50 @@ func (ms *ModelSelectorComponent) IsActive() bool {
return ms.active
}
// --- Internal helpers ---
// --- Model-specific fuzzy filter ---
func (ms *ModelSelectorComponent) visibleHeight() int {
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7.
// Minimum 3 entries so the selector is still usable on short terminals.
return max(ms.height-7, 3)
}
// filterModels scores and filters PopupItems whose Meta is a ModelEntry.
func filterModels(query string, items []PopupItem) []PopupItem {
if query == "" {
return items
}
q := strings.ToLower(query)
func (ms *ModelSelectorComponent) rebuildFiltered() {
if ms.search == "" {
ms.filtered = ms.allModels
} else {
query := strings.ToLower(ms.search)
ms.filtered = ms.filtered[:0]
type scored struct {
item PopupItem
score int
}
var matches []scored
type scored struct {
entry ModelEntry
score int
for _, item := range items {
entry, ok := item.Meta.(ModelEntry)
if !ok {
continue
}
var matches []scored
for _, entry := range ms.allModels {
s := ms.fuzzyScoreModel(query, entry)
if s > 0 {
matches = append(matches, scored{entry: entry, score: s})
}
}
// Sort by score descending, then alphabetically.
sort.Slice(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return matches[i].entry.ModelID < matches[j].entry.ModelID
})
ms.filtered = make([]ModelEntry, len(matches))
for i, m := range matches {
ms.filtered[i] = m.entry
s := fuzzyScoreModelEntry(q, entry)
if s > 0 {
matches = append(matches, scored{item: item, score: s})
}
}
// Clamp cursor.
if ms.cursor >= len(ms.filtered) {
ms.cursor = max(len(ms.filtered)-1, 0)
sort.Slice(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
a := matches[i].item.Meta.(ModelEntry)
b := matches[j].item.Meta.(ModelEntry)
return a.ModelID < b.ModelID
})
result := make([]PopupItem, len(matches))
for i, m := range matches {
result[i] = m.item
}
return result
}
// fuzzyScoreModel scores a model entry against the search query.
func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry) int {
// fuzzyScoreModelEntry scores a model entry against the search query.
func fuzzyScoreModelEntry(query string, entry ModelEntry) int {
modelID := strings.ToLower(entry.ModelID)
provider := strings.ToLower(entry.Provider)
name := strings.ToLower(entry.Name)
@@ -391,67 +269,3 @@ func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry
return 0
}
func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string {
theme := GetTheme()
modelStr := entry.ModelID
providerStr := fmt.Sprintf("[%s]", entry.Provider)
// Cursor indicator.
var cursor string
if isCursor {
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("-> ")
} else {
cursor = " "
}
// Active model checkmark.
var active string
activeWidth := 0
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
activeWidth = 2 // " ✓"
}
// Truncate model ID and provider tag to fit terminal width.
// Layout: cursor(3) + model + " " + provider + active.
// Use rune length for display-width accuracy (the "…" suffix is 1 rune / 1 column).
const cursorWidth = 3
available := max(ms.width-cursorWidth-activeWidth-1, 10) // 1 for space between model and provider
provDisplayLen := len([]rune(providerStr))
modelDisplayLen := len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
// Prioritize model name — truncate it, but keep provider visible.
maxModel := max(available-provDisplayLen-1, 6)
if maxModel < modelDisplayLen {
if maxModel > 3 {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel-1]) + "…"
} else {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel])
}
}
// If provider itself is too long, drop it.
modelDisplayLen = len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
providerStr = ""
}
}
// Style the model ID.
modelStyle := lipgloss.NewStyle().Foreground(theme.Text)
if isCursor {
modelStyle = modelStyle.Bold(true).Foreground(theme.Accent)
}
// Style the provider tag.
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
result := cursor + modelStyle.Render(modelStr)
if providerStr != "" {
result += " " + providerStyle.Render(providerStr)
}
return result + active
}
+12 -5
View File
@@ -7,6 +7,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/ui/core"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -80,6 +81,11 @@ func (s *stubAppController) Steer(prompt string) int {
return s.queueLen
}
func (s *stubAppController) SteerWithFiles(prompt string, _ []kit.LLMFilePart) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
@@ -167,7 +173,7 @@ func TestStateTransition_InputToWorking(t *testing.T) {
t.Fatalf("expected stateInput, got %v", m.state)
}
m = sendMsg(m, submitMsg{Text: "hello"})
m = sendMsg(m, core.SubmitMsg{Text: "hello"})
if m.state != stateWorking {
t.Fatalf("expected stateWorking after submitMsg, got %v", m.state)
@@ -355,7 +361,7 @@ func TestESCCancel_timerExpiry(t *testing.T) {
m.state = stateWorking
m.canceling = true
m = sendMsg(m, cancelTimerExpiredMsg{})
m = sendMsg(m, core.CancelTimerExpiredMsg{})
if m.canceling {
t.Fatal("expected canceling=false after timer expiry")
@@ -408,7 +414,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(submitMsg{Text: "queued prompt"})
_, cmd := m.Update(core.SubmitMsg{Text: "queued prompt"})
if len(m.queuedMessages) != 1 {
t.Fatalf("expected 1 queued message, got %d", len(m.queuedMessages))
@@ -557,7 +563,7 @@ func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m = sendMsg(m, submitMsg{Text: "user query"})
m = sendMsg(m, core.SubmitMsg{Text: "user query"})
// In alt screen mode, user messages are added to the in-memory ScrollList
// rather than printed separately. Verify the message was added.
@@ -667,6 +673,7 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
}
if bashItem == nil {
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
return
}
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
@@ -876,7 +883,7 @@ func TestSubmit_duringWorking_stays(t *testing.T) {
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, submitMsg{Text: "queued prompt"})
m = sendMsg(m, core.SubmitMsg{Text: "queued prompt"})
if m.state != stateWorking {
t.Fatalf("expected stateWorking to persist after submitMsg during working, got %v", m.state)
+4 -2
View File
@@ -6,6 +6,8 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// ---------------------------------------------------------------------------
@@ -133,7 +135,7 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd)
// composition. The dialog is a bordered box centered (or anchored)
// horizontally within the terminal width.
func (o *overlayDialog) Render() string {
theme := GetTheme()
theme := style.GetTheme()
// Calculate dialog dimensions, clamped to terminal bounds.
termW := max(o.width, 10)
@@ -157,7 +159,7 @@ func (o *overlayDialog) Render() string {
// Render body text (potentially as markdown).
bodyText := o.content
if o.markdown {
bodyText = toMarkdown(bodyText, innerWidth)
bodyText = style.ToMarkdown(bodyText, innerWidth)
}
bodyText = strings.TrimRight(bodyText, "\n")
+501
View File
@@ -0,0 +1,501 @@
package ui
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// PopupItem represents a single entry in a PopupList. The component renders
// Label as the primary text and Description as secondary text to its right.
// The Active flag renders a checkmark to indicate the currently-active item
// (e.g. the current model). Meta is opaque caller data returned on selection.
type PopupItem struct {
Label string // primary display text
Description string // secondary text (shown right of label)
Active bool // true → render checkmark indicator
Meta any // opaque data returned on selection
}
// PopupList is a generic, themed, scrollable fuzzy-find popup list. It is
// rendered as a centered overlay on top of the normal TUI layout and can be
// reused by any feature that needs a selection popup (slash commands, model
// selector, session picker, extension-provided lists, etc.).
//
// The caller is responsible for:
// - Building the initial item list
// - Providing a fuzzy-filter callback (or nil for substring matching)
// - Handling the result when the user selects or cancels
//
// Navigation: up/down to move, enter to select, esc to cancel, type to filter.
type PopupList struct {
// Title shown at the top of the popup.
Title string
// Subtitle shown below the title (dimmed).
Subtitle string
// FooterHint overrides the default keyboard-hint footer.
FooterHint string
allItems []PopupItem // full unfiltered list
filtered []PopupItem // subset matching the current search
cursor int
search string
// FilterFunc is called with (query, allItems) and should return the
// filtered+scored subset. When nil, a default substring match is used.
FilterFunc func(query string, items []PopupItem) []PopupItem
width int
height int
maxVisible int // max items visible at once (0 = auto from height)
showSearch bool
}
// PopupResult is returned by HandleKey to tell the caller what happened.
type PopupResult struct {
// Selected is non-nil when the user pressed Enter on an item.
Selected *PopupItem
// Cancelled is true when the user pressed Esc with no search text.
Cancelled bool
// Changed is true when the search or cursor moved (caller should re-render).
Changed bool
}
// NewPopupList creates a new popup list with the given items and dimensions.
func NewPopupList(title string, items []PopupItem, width, height int) *PopupList {
p := &PopupList{
Title: title,
allItems: items,
filtered: items,
width: width,
height: height,
showSearch: true,
}
// Position cursor on the active item if one exists.
for i, item := range p.filtered {
if item.Active {
p.cursor = i
break
}
}
return p
}
// SetSize updates the popup dimensions (e.g. on window resize).
func (p *PopupList) SetSize(width, height int) {
p.width = width
p.height = height
}
// visibleCount returns the number of items visible at once.
func (p *PopupList) visibleCount() int {
if p.maxVisible > 0 {
return p.maxVisible
}
// Reserve: title(1) + subtitle(1) + search(1) + separator(1) + footer(2) + border(2) + padding(2) = 10
overhead := 8
if p.Subtitle != "" {
overhead++
}
if p.showSearch {
overhead += 2 // search line + separator
}
return max(p.height/2-overhead, 3)
}
// HandleKey processes a single key event and returns the result. The caller
// should inspect PopupResult to decide whether to re-render, close the popup,
// or act on a selection.
//
// keyName is the Bubble Tea key string (e.g. "up", "down", "enter", "esc").
// keyText is the printable text for character keys (e.g. "a", "1").
func (p *PopupList) HandleKey(keyName, keyText string) PopupResult {
switch keyName {
case "up":
if p.cursor > 0 {
p.cursor--
return PopupResult{Changed: true}
}
return PopupResult{}
case "down":
if p.cursor < len(p.filtered)-1 {
p.cursor++
return PopupResult{Changed: true}
}
return PopupResult{}
case "pgup":
p.cursor -= p.visibleCount()
if p.cursor < 0 {
p.cursor = 0
}
return PopupResult{Changed: true}
case "pgdown":
p.cursor += p.visibleCount()
if p.cursor >= len(p.filtered) {
p.cursor = max(len(p.filtered)-1, 0)
}
return PopupResult{Changed: true}
case "home":
p.cursor = 0
return PopupResult{Changed: true}
case "end":
p.cursor = max(len(p.filtered)-1, 0)
return PopupResult{Changed: true}
case "enter":
if p.cursor < len(p.filtered) {
item := p.filtered[p.cursor]
return PopupResult{Selected: &item}
}
return PopupResult{}
case "esc":
if p.search != "" {
p.search = ""
p.rebuildFiltered()
return PopupResult{Changed: true}
}
return PopupResult{Cancelled: true}
case "backspace":
if len(p.search) > 0 {
p.search = p.search[:len(p.search)-1]
p.rebuildFiltered()
return PopupResult{Changed: true}
}
return PopupResult{}
default:
// Printable character → append to search.
if keyText != "" && len(keyText) == 1 {
ch := keyText[0]
if ch >= 32 && ch < 127 {
p.search += string(ch)
p.rebuildFiltered()
return PopupResult{Changed: true}
}
}
return PopupResult{}
}
}
// Render returns the styled popup content (bordered box) ready to be placed
// as a centered overlay via lipgloss.Place + overlayContent.
func (p *PopupList) Render() string {
theme := style.GetTheme()
popupWidth := max(min(p.width-4, 80), 20)
popupBg := theme.Background
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.Primary).
Background(popupBg).
Padding(1, 2).
Width(popupWidth).
MarginBottom(1)
// Inner content width: popup minus border (2) and horizontal padding (4).
innerWidth := max(popupWidth-6, 10)
var b strings.Builder
// Title.
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(theme.Accent).
Background(popupBg).
Width(innerWidth)
b.WriteString(titleStyle.Render(p.Title))
b.WriteString("\n")
// Subtitle.
if p.Subtitle != "" {
subtitleStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(popupBg).
Width(innerWidth)
b.WriteString(subtitleStyle.Render(p.Subtitle))
b.WriteString("\n")
}
// Search input.
if p.showSearch {
searchStyle := lipgloss.NewStyle().
Foreground(theme.Info).
Background(popupBg).
Width(innerWidth)
if p.search != "" {
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", p.search)))
} else {
b.WriteString(searchStyle.Render("> "))
}
b.WriteString("\n")
// Separator.
sepStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(popupBg)
b.WriteString(sepStyle.Render(strings.Repeat("─", innerWidth)))
b.WriteString("\n")
}
// Item list.
normalItemBg := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.Text).
Width(innerWidth).
Padding(0, 1)
selectedItemBg := lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Background).
Width(innerWidth).
Padding(0, 1).
Bold(true)
scrollStyle := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.VeryMuted).
Width(innerWidth).
Padding(0, 1)
vis := p.visibleCount()
var items []string
if len(p.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(popupBg).
Width(innerWidth).
Padding(0, 1)
if p.search != "" {
items = append(items, emptyStyle.Render("No matches for \""+p.search+"\""))
} else {
items = append(items, emptyStyle.Render("No items"))
}
} else {
startIdx := 0
if p.cursor >= vis {
startIdx = p.cursor - vis + 1
}
endIdx := min(startIdx+vis, len(p.filtered))
if startIdx > 0 {
items = append(items, scrollStyle.Render(" ↑ more above"))
}
for i := startIdx; i < endIdx; i++ {
entry := p.filtered[i]
isCursor := i == p.cursor
itemStyle := normalItemBg
if isCursor {
itemStyle = selectedItemBg
}
// Build indicator.
var indicator string
if isCursor {
indicator = "> "
} else {
indicator = " "
}
// Build content: indicator + label + description + active checkmark.
content := p.renderItemContent(indicator, entry, innerWidth, isCursor)
items = append(items, itemStyle.Render(content))
}
if endIdx < len(p.filtered) {
items = append(items, scrollStyle.Render(" ↓ more below"))
}
}
content := b.String() + strings.Join(items, "\n")
// Footer with count and keyboard hints.
var footerParts []string
footerParts = append(footerParts, fmt.Sprintf("(%d/%d)", p.cursor+1, len(p.filtered)))
footerHint := p.FooterHint
if footerHint == "" {
if innerWidth >= 50 {
footerHint = "↑↓ navigate • enter select • esc cancel • type to filter"
} else if innerWidth >= 30 {
footerHint = "↑↓ nav • ↵ select • esc"
} else {
footerHint = "↑↓ ↵ esc"
}
}
footerParts = append(footerParts, footerHint)
footer := lipgloss.NewStyle().
Background(popupBg).
Foreground(theme.VeryMuted).
Italic(true).
Render(strings.Join(footerParts, " "))
return popupStyle.Render(content + "\n\n" + footer)
}
// RenderCentered returns the popup placed at the center of a termWidth×termHeight
// canvas, ready to be composed with overlayContent().
func (p *PopupList) RenderCentered(termWidth, termHeight int) string {
popupContent := p.Render()
return lipgloss.Place(
termWidth,
termHeight,
lipgloss.Center,
lipgloss.Center,
popupContent,
)
}
// IsSearching returns true when the search input is non-empty.
func (p *PopupList) IsSearching() bool {
return p.search != ""
}
// SelectedItem returns the item under the cursor, or nil if the list is empty.
func (p *PopupList) SelectedItem() *PopupItem {
if p.cursor < len(p.filtered) {
item := p.filtered[p.cursor]
return &item
}
return nil
}
// --- Internal helpers ---
func (p *PopupList) rebuildFiltered() {
if p.FilterFunc != nil {
p.filtered = p.FilterFunc(p.search, p.allItems)
} else {
p.filtered = defaultFilter(p.search, p.allItems)
}
// Clamp cursor.
if p.cursor >= len(p.filtered) {
p.cursor = max(len(p.filtered)-1, 0)
}
}
// defaultFilter is a simple case-insensitive substring + fuzzy character match.
func defaultFilter(query string, items []PopupItem) []PopupItem {
if query == "" {
return items
}
q := strings.ToLower(query)
type scored struct {
item PopupItem
score int
}
var matches []scored
for _, item := range items {
label := strings.ToLower(item.Label)
desc := strings.ToLower(item.Description)
var s int
switch {
case label == q:
s = 1000
case strings.HasPrefix(label, q):
s = 800 - len(label) + len(q)
case strings.Contains(label, q):
s = 600
case strings.Contains(desc, q):
s = 400
default:
s = fuzzyCharacterMatch(q, label)
}
if s > 0 {
matches = append(matches, scored{item: item, score: s})
}
}
// Sort by score descending, then alphabetically by label.
for i := 0; i < len(matches)-1; i++ {
for j := i + 1; j < len(matches); j++ {
if matches[j].score > matches[i].score ||
(matches[j].score == matches[i].score && matches[j].item.Label < matches[i].item.Label) {
matches[i], matches[j] = matches[j], matches[i]
}
}
}
result := make([]PopupItem, len(matches))
for i, m := range matches {
result[i] = m.item
}
return result
}
// renderItemContent builds the display string for a single item row.
func (p *PopupList) renderItemContent(indicator string, entry PopupItem, innerWidth int, isCursor bool) string {
theme := style.GetTheme()
// Reserve space: indicator(2) + potential checkmark(2)
activeWidth := 0
if entry.Active {
activeWidth = 2
}
available := max(innerWidth-2-activeWidth, 6) // 2 for indicator, already included
label := entry.Label
desc := entry.Description
if desc != "" {
// Two-column layout: label + description.
descWidth := len([]rune(desc)) + 1 // 1 space gap
labelMax := max(available-descWidth, available*2/3)
if len([]rune(label)) > labelMax && labelMax > 3 {
runes := []rune(label)
label = string(runes[:labelMax-1]) + "…"
}
labelDisplayLen := len([]rune(label))
// If label + desc don't fit, truncate or drop desc.
if labelDisplayLen+1+len([]rune(desc)) > available {
remaining := available - labelDisplayLen - 1
if remaining >= 4 {
runes := []rune(desc)
if len(runes) > remaining {
desc = string(runes[:remaining-1]) + "…"
}
} else {
desc = ""
}
}
} else {
// Single column: just the label.
if len([]rune(label)) > available && available > 3 {
runes := []rune(label)
label = string(runes[:available-1]) + "…"
}
}
result := indicator + label
if desc != "" {
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
if isCursor {
// When selected, use a dimmer foreground that still contrasts with Primary bg.
descStyle = lipgloss.NewStyle().Foreground(theme.Background)
}
result += " " + descStyle.Render(desc)
}
if entry.Active {
checkStyle := lipgloss.NewStyle().Foreground(theme.Success)
if isCursor {
checkStyle = lipgloss.NewStyle().Foreground(theme.Background)
}
result += checkStyle.Render(" ✓")
}
return result
}
+297
View File
@@ -0,0 +1,297 @@
package ui
import (
"strings"
"testing"
)
func TestPopupList_NewPositionsCursorOnActiveItem(t *testing.T) {
items := []PopupItem{
{Label: "alpha"},
{Label: "beta"},
{Label: "gamma", Active: true},
{Label: "delta"},
}
p := NewPopupList("Test", items, 80, 40)
if p.cursor != 2 {
t.Errorf("expected cursor on active item (index 2), got %d", p.cursor)
}
}
func TestPopupList_HandleKey_Navigation(t *testing.T) {
items := []PopupItem{
{Label: "alpha"},
{Label: "beta"},
{Label: "gamma"},
}
p := NewPopupList("Test", items, 80, 40)
// Initial cursor at 0.
if p.cursor != 0 {
t.Fatalf("expected cursor 0, got %d", p.cursor)
}
// Down → 1.
res := p.HandleKey("down", "")
if !res.Changed || p.cursor != 1 {
t.Errorf("down: changed=%v cursor=%d", res.Changed, p.cursor)
}
// Down → 2.
p.HandleKey("down", "")
if p.cursor != 2 {
t.Errorf("expected cursor 2, got %d", p.cursor)
}
// Down at end → stays at 2.
res = p.HandleKey("down", "")
if p.cursor != 2 {
t.Errorf("down at end: expected cursor 2, got %d", p.cursor)
}
// Up → 1.
res = p.HandleKey("up", "")
if !res.Changed || p.cursor != 1 {
t.Errorf("up: changed=%v cursor=%d", res.Changed, p.cursor)
}
// Home → 0.
p.HandleKey("home", "")
if p.cursor != 0 {
t.Errorf("home: expected cursor 0, got %d", p.cursor)
}
// End → 2.
p.HandleKey("end", "")
if p.cursor != 2 {
t.Errorf("end: expected cursor 2, got %d", p.cursor)
}
}
func TestPopupList_HandleKey_Search(t *testing.T) {
items := []PopupItem{
{Label: "apple"},
{Label: "banana"},
{Label: "cherry"},
}
p := NewPopupList("Test", items, 80, 40)
// Type "an" → should filter to banana.
p.HandleKey("a", "a")
p.HandleKey("n", "n")
if !p.IsSearching() {
t.Error("expected IsSearching() to be true")
}
if len(p.filtered) == 0 {
t.Fatal("expected at least one filtered result")
}
// banana should match (contains "an").
found := false
for _, item := range p.filtered {
if item.Label == "banana" {
found = true
break
}
}
if !found {
t.Error("expected 'banana' in filtered results")
}
// Backspace removes last char.
p.HandleKey("backspace", "")
if p.search != "a" {
t.Errorf("expected search 'a' after backspace, got %q", p.search)
}
// Esc clears search.
res := p.HandleKey("esc", "")
if res.Cancelled {
t.Error("esc with search should clear search, not cancel")
}
if p.search != "" {
t.Errorf("expected empty search after esc, got %q", p.search)
}
}
func TestPopupList_HandleKey_SelectAndCancel(t *testing.T) {
items := []PopupItem{
{Label: "alpha", Meta: "first"},
{Label: "beta", Meta: "second"},
}
p := NewPopupList("Test", items, 80, 40)
// Select first item.
res := p.HandleKey("enter", "")
if res.Selected == nil {
t.Fatal("expected a selection on enter")
}
if res.Selected.Label != "alpha" {
t.Errorf("expected 'alpha', got %q", res.Selected.Label)
}
if res.Selected.Meta != "first" {
t.Errorf("expected meta 'first', got %v", res.Selected.Meta)
}
// Cancel with esc (no search text).
p2 := NewPopupList("Test", items, 80, 40)
res = p2.HandleKey("esc", "")
if !res.Cancelled {
t.Error("expected Cancelled on esc with no search")
}
}
func TestPopupList_DefaultFilter(t *testing.T) {
items := []PopupItem{
{Label: "foo-bar"},
{Label: "baz-qux"},
{Label: "foobar"},
}
// Exact prefix.
result := defaultFilter("foo", items)
if len(result) < 2 {
t.Fatalf("expected at least 2 matches for 'foo', got %d", len(result))
}
// "foobar" should rank higher (shorter match) or equal to "foo-bar".
if result[0].Label != "foobar" && result[1].Label != "foobar" {
t.Error("expected 'foobar' in top results")
}
// No match.
result = defaultFilter("zzz", items)
if len(result) != 0 {
t.Errorf("expected 0 matches for 'zzz', got %d", len(result))
}
}
func TestPopupList_CustomFilterFunc(t *testing.T) {
items := []PopupItem{
{Label: "alpha"},
{Label: "beta"},
{Label: "gamma"},
}
p := NewPopupList("Test", items, 80, 40)
p.FilterFunc = func(query string, allItems []PopupItem) []PopupItem {
// Custom: only return items whose label starts with query.
var result []PopupItem
for _, item := range allItems {
if strings.HasPrefix(item.Label, query) {
result = append(result, item)
}
}
return result
}
p.HandleKey("b", "b")
if len(p.filtered) != 1 || p.filtered[0].Label != "beta" {
t.Errorf("expected ['beta'], got %v", p.filtered)
}
}
func TestPopupList_Render(t *testing.T) {
items := []PopupItem{
{Label: "alpha", Description: "[test]"},
{Label: "beta", Description: "[test]", Active: true},
}
p := NewPopupList("My List", items, 80, 40)
p.Subtitle = "Some subtitle"
rendered := p.Render()
if rendered == "" {
t.Fatal("expected non-empty rendered output")
}
// Strip ANSI escape sequences for content checking.
plain := stripAnsi(rendered)
if !strings.Contains(plain, "My List") {
t.Error("expected title 'My List' in rendered output")
}
if !strings.Contains(plain, "alpha") {
t.Error("expected 'alpha' in rendered output")
}
if !strings.Contains(plain, "beta") {
t.Error("expected 'beta' in rendered output")
}
if !strings.Contains(plain, "✓") {
t.Error("expected checkmark for active item")
}
}
func TestPopupList_RenderCentered(t *testing.T) {
items := []PopupItem{
{Label: "item1"},
}
p := NewPopupList("Test", items, 80, 40)
centered := p.RenderCentered(80, 40)
if centered == "" {
t.Fatal("expected non-empty centered output")
}
// Should contain newlines for vertical centering.
lines := strings.Split(centered, "\n")
if len(lines) < 10 {
t.Errorf("expected centered output to have many lines, got %d", len(lines))
}
}
func TestPopupList_EmptyItems(t *testing.T) {
p := NewPopupList("Empty", nil, 80, 40)
rendered := p.Render()
if !strings.Contains(rendered, "No items") {
t.Error("expected 'No items' for empty list")
}
// Navigate on empty list shouldn't panic.
p.HandleKey("down", "")
p.HandleKey("up", "")
res := p.HandleKey("enter", "")
if res.Selected != nil {
t.Error("enter on empty list should not select")
}
}
func TestPopupList_SearchNoResults(t *testing.T) {
items := []PopupItem{
{Label: "alpha"},
{Label: "beta"},
}
p := NewPopupList("Test", items, 80, 40)
// Type something that doesn't match.
p.HandleKey("z", "z")
p.HandleKey("z", "z")
p.HandleKey("z", "z")
rendered := p.Render()
if !strings.Contains(rendered, "No matches") {
t.Error("expected 'No matches' message for empty search results")
}
}
func TestPopupList_CursorClamping(t *testing.T) {
items := []PopupItem{
{Label: "alpha"},
{Label: "beta"},
{Label: "gamma"},
}
p := NewPopupList("Test", items, 80, 40)
// Move to last item.
p.HandleKey("end", "")
if p.cursor != 2 {
t.Fatalf("expected cursor 2, got %d", p.cursor)
}
// Search that reduces list to 1 item → cursor should clamp.
p.HandleKey("a", "a")
p.HandleKey("l", "l")
// Only "alpha" should match.
if p.cursor >= len(p.filtered) {
t.Errorf("cursor %d should be < filtered count %d", p.cursor, len(p.filtered))
}
}
// stripAnsi is defined in usage_tracker_render_test.go
@@ -1,4 +1,4 @@
package ui
package prefs
import (
"os"
+6 -4
View File
@@ -7,6 +7,8 @@ import (
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// ---------------------------------------------------------------------------
@@ -204,7 +206,7 @@ func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd
// AppModel layout. The prompt replaces the normal input area (below the
// separator and above the status bar) rather than taking over the full screen.
func (p *promptOverlay) Render() string {
theme := GetTheme()
theme := style.GetTheme()
var content string
switch p.mode {
@@ -224,7 +226,7 @@ func (p *promptOverlay) Render() string {
)
}
func (p *promptOverlay) viewSelect(theme Theme) string {
func (p *promptOverlay) viewSelect(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
@@ -247,7 +249,7 @@ func (p *promptOverlay) viewSelect(theme Theme) string {
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewConfirm(theme Theme) string {
func (p *promptOverlay) viewConfirm(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
@@ -272,7 +274,7 @@ func (p *promptOverlay) viewConfirm(theme Theme) string {
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewInput(theme Theme) string {
func (p *promptOverlay) viewInput(theme style.Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
+127
View File
@@ -0,0 +1,127 @@
// Package render provides pure rendering functions for message blocks.
// These functions are stateless and can be used by both streaming and
// historical message rendering paths, eliminating code duplication.
package render
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"github.com/mark3labs/kit/internal/ui/style"
)
// UserBlock renders a user message with herald Tip styling.
func UserBlock(content string, ty *herald.Typography, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
content = "(empty message)"
}
rendered := ty.Tip(content)
return styleMarginBottom(theme, rendered)
}
// AssistantBlock renders an assistant message with markdown styling.
func AssistantBlock(content string, width int, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
return ""
}
rendered := style.ToMarkdown(content, width-4)
return styleMarginBottom(theme, rendered)
}
// ReasoningBlock renders a reasoning/thinking block with muted italic text.
// If duration > 0, shows "Thought for Xs" label. Otherwise shows just "Thought".
func ReasoningBlock(content string, duration int64, ty *herald.Typography, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
return ""
}
// Match live streaming styling: muted italic text
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
contentRendered := mutedStyle.Render(ty.Italic(contentStr))
// Build label based on duration
if duration > 0 {
var durationStr string
if duration < 1000 {
durationStr = fmt.Sprintf("%dms", duration)
} else {
durationStr = fmt.Sprintf("%.1fs", float64(duration)/1000)
}
labelPart := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationPart := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
label := labelPart + durationPart
rendered := contentRendered + "\n" + label
return styleMarginBottom(theme, rendered)
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought")
rendered := contentRendered + "\n" + label
return styleMarginBottom(theme, rendered)
}
// SystemBlock renders a system message with herald Note styling.
func SystemBlock(content string, ty *herald.Typography, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
content = "No content available"
}
rendered := ty.Note(content)
return styleMarginBottom(theme, rendered)
}
// ErrorBlock renders an error message with herald Caution styling.
func ErrorBlock(errorMsg string, ty *herald.Typography, theme style.Theme) string {
rendered := ty.Caution(errorMsg)
return styleMarginBottom(theme, rendered)
}
// ToolBlock renders a tool execution result with header and body.
func ToolBlock(displayName, params, body string, isError bool, width int, ty *herald.Typography, theme style.Theme) string {
var icon string
iconColor := theme.Success
if isError {
icon = "×"
iconColor = theme.Error
} else {
icon = "✓"
}
// Style the tool name with color
nameColor := theme.Info
if isError {
nameColor = theme.Error
}
styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName)
styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon)
// Build the content: icon + name + params on first line, then body
headerLine := styledIcon + " " + styledName
if params != "" {
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
if strings.TrimSpace(body) == "" {
body = ty.Italic("(no output)")
}
// Compose: icon + name + params, then body
fullContent := ty.Compose(
headerLine,
"",
body,
)
return styleMarginBottom(theme, fullContent)
}
// styleMarginBottom applies a 1-line margin bottom using the theme.
func styleMarginBottom(theme style.Theme, content string) string {
return lipgloss.NewStyle().MarginBottom(1).Render(content)
}
+253 -258
View File
@@ -2,25 +2,13 @@ package ui
import (
"strings"
"time"
"charm.land/lipgloss/v2"
xansi "github.com/charmbracelet/x/ansi"
"github.com/mark3labs/kit/internal/ui/selection"
)
// highlightStyle is lazily initialized to avoid creating it on every render
var highlightStyle lipgloss.Style
// initHighlightStyle creates the highlight style with proper colors
func initHighlightStyle() lipgloss.Style {
if highlightStyle.String() == "" {
theme := GetTheme()
highlightStyle = lipgloss.NewStyle().
Background(theme.Secondary).
Foreground(theme.Background).
Bold(true)
}
return highlightStyle
}
// MessageItem is the interface all scrollback messages must implement.
// This allows lazy rendering - messages are only rendered when visible.
type MessageItem interface {
@@ -36,8 +24,8 @@ type MessageItem interface {
}
// ScrollList manages a viewport over a list of MessageItems.
// It handles offset-based scrolling and lazy rendering. Only visible
// items are rendered on each View() call.
// It handles offset-based scrolling, lazy rendering, and character-level
// text selection (crush-style). Only visible items are rendered on each View() call.
type ScrollList struct {
items []MessageItem
offsetIdx int // Index of first visible item
@@ -46,15 +34,9 @@ type ScrollList struct {
height int // Viewport height in lines
autoScroll bool // Whether to auto-scroll to bottom on new content
itemGap int // Number of blank lines between items (0 = no gap)
focusedIdx int // Index of focused/selected item (-1 = none)
selectable bool // Whether items can be selected via mouse/keyboard
// Selection tracking for copy+paste (crush-style)
selection CopySelection // Current text selection
mouseDown bool // Whether mouse button is currently down
mouseDownX int // X coordinate where mouse was pressed
mouseDownY int // Y coordinate where mouse was pressed
mouseDownItem int // Item index where mouse was pressed
// Character-level text selection (crush-style).
sel selection.State
}
// NewScrollList creates a new ScrollList with the given dimensions.
@@ -65,7 +47,8 @@ func NewScrollList(width, height int) *ScrollList {
offsetLine: 0,
width: width,
height: height,
autoScroll: true, // Start with auto-scroll enabled
autoScroll: true,
sel: selection.NewState(),
}
}
@@ -101,118 +84,210 @@ func (s *ScrollList) ItemGap() int {
return s.itemGap
}
// SetSelectable enables or disables item selection.
func (s *ScrollList) SetSelectable(selectable bool) {
s.selectable = selectable
}
// --------------------------------------------------------------------------
// Mouse event handling — character-level text selection (crush-style)
// --------------------------------------------------------------------------
// FocusedIdx returns the currently focused item index (-1 if none).
func (s *ScrollList) FocusedIdx() int {
return s.focusedIdx
}
// SetFocused sets the focused item by index.
func (s *ScrollList) SetFocused(idx int) {
if idx < -1 {
s.focusedIdx = -1
} else if idx >= len(s.items) {
s.focusedIdx = len(s.items) - 1
} else {
s.focusedIdx = idx
}
}
// SelectItemAtY selects the item at the given Y coordinate (relative to viewport).
// Returns the selected item index or -1 if no item at that position.
func (s *ScrollList) SelectItemAtY(y int) int {
if !s.selectable || len(s.items) == 0 || y < 0 || y >= s.height {
return -1
}
// Calculate which item is at the given Y position
currentY := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Check if y falls within this item
if y >= currentY && y < currentY+itemHeight {
s.focusedIdx = idx
return idx
}
currentY += itemHeight
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
currentY += s.itemGap
}
// Stop if we've passed the viewport
if currentY >= s.height {
break
}
}
return -1
}
// HandleMouseDown handles mouse button press for selection (crush-style).
// HandleMouseDown handles mouse button press. Detects single, double, and
// triple clicks for character, word, and line selection respectively.
// Returns true if the click was handled.
func (s *ScrollList) HandleMouseDown(x, y int) bool {
if !s.selectable || len(s.items) == 0 {
if len(s.items) == 0 {
return false
}
s.mouseDown = true
s.mouseDownX = x
s.mouseDownY = y
// Find which item and line was clicked
itemIdx, lineIdx := s.getItemAndLineAtY(y)
s.mouseDownItem = itemIdx
// Start a new selection at click position
if itemIdx >= 0 {
s.selection = CopySelection{
StartItemIdx: itemIdx,
StartLine: lineIdx,
StartCol: x,
EndItemIdx: itemIdx,
EndLine: lineIdx,
EndCol: x,
Active: true,
}
return true
}
return false
}
// HandleMouseDrag handles mouse drag for selection (crush-style).
// Updates the selection end point. Returns true if selection changed.
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
if !s.mouseDown || !s.selectable {
return false
}
// Find which item and line we're dragging over
itemIdx, lineIdx := s.getItemAndLineAtY(y)
if itemIdx < 0 {
return false
}
// Update selection end point
s.selection.EndItemIdx = itemIdx
s.selection.EndLine = lineIdx
s.selection.EndCol = x
s.selection.Active = true
// Multi-click detection (crush-style).
now := time.Now()
if now.Sub(s.sel.LastClickTime) <= selection.DoubleClickThreshold &&
abs(x-s.sel.LastClickX) <= selection.ClickTolerance &&
abs(y-s.sel.LastClickY) <= selection.ClickTolerance {
s.sel.ClickCount++
} else {
s.sel.ClickCount = 1
}
s.sel.LastClickTime = now
s.sel.LastClickX = x
s.sel.LastClickY = y
switch s.sel.ClickCount {
case 1:
// Single click: start character-level drag selection.
s.sel.MouseDown = true
s.sel.MouseDownItemIdx = itemIdx
s.sel.MouseDownLineIdx = lineIdx
s.sel.MouseDownCol = x
s.sel.DragItemIdx = itemIdx
s.sel.DragLineIdx = lineIdx
s.sel.DragCol = x
case 2:
// Double click: select word at position.
s.selectWord(itemIdx, lineIdx, x)
case 3:
// Triple click: select entire line.
s.selectLine(itemIdx, lineIdx)
s.sel.ClickCount = 0 // Reset after triple
}
return true
}
// getItemAndLineAtY converts a Y coordinate to item index and line index within that item.
// HandleMouseDrag handles mouse motion while button is held.
// Updates the selection endpoint for character-level precision.
// Returns true if selection was updated.
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
if !s.sel.MouseDown {
return false
}
if len(s.items) == 0 {
return false
}
itemIdx, lineIdx := s.getItemAndLineAtY(y)
if itemIdx < 0 {
return false
}
s.sel.DragItemIdx = itemIdx
s.sel.DragLineIdx = lineIdx
s.sel.DragCol = x
return true
}
// HandleMouseUp handles mouse button release.
// Returns true if there was an active selection.
func (s *ScrollList) HandleMouseUp() bool {
if !s.sel.MouseDown {
return false
}
s.sel.MouseDown = false
return s.sel.HasSelection()
}
// HasSelection returns true if there is a non-empty active selection.
func (s *ScrollList) HasSelection() bool {
return s.sel.HasSelection()
}
// ClearSelection clears the current text selection.
func (s *ScrollList) ClearSelection() {
s.sel.Clear()
}
// ExtractSelectedText returns the plain text content of the current selection
// by walking through selected items and extracting text at the character level
// using the ultraviolet cell buffer (ANSI-aware).
func (s *ScrollList) ExtractSelectedText() string {
r := s.sel.GetRange()
if r.IsEmpty() {
return ""
}
var sb strings.Builder
for itemIdx := r.StartItemIdx; itemIdx <= r.EndItemIdx && itemIdx < len(s.items); itemIdx++ {
item := s.items[itemIdx]
content := item.Render(s.width)
contentLines := strings.Split(content, "\n")
for lineIdx, line := range contentLines {
inRange, startCol, endCol := selection.IsLineInRange(r, itemIdx, lineIdx)
if !inRange {
continue
}
text := selection.ExtractText(line, startCol, endCol)
if text != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(text)
}
}
}
return sb.String()
}
// selectWord selects the word at the given position using UAX#29 word
// segmentation and display-width-aware column calculations.
func (s *ScrollList) selectWord(itemIdx, lineIdx, x int) {
if itemIdx < 0 || itemIdx >= len(s.items) {
return
}
item := s.items[itemIdx]
content := item.Render(s.width)
lines := strings.Split(content, "\n")
if lineIdx < 0 || lineIdx >= len(lines) {
return
}
// Strip ANSI codes for word boundary detection.
plainLine := xansi.Strip(lines[lineIdx])
startCol, endCol := selection.FindWordBoundaries(plainLine, x)
if startCol == endCol {
// No word at this position — set up single-click drag state.
s.sel.MouseDown = true
s.sel.MouseDownItemIdx = itemIdx
s.sel.MouseDownLineIdx = lineIdx
s.sel.MouseDownCol = x
s.sel.DragItemIdx = itemIdx
s.sel.DragLineIdx = lineIdx
s.sel.DragCol = x
return
}
// Set selection to the word boundaries.
s.sel.MouseDown = true
s.sel.MouseDownItemIdx = itemIdx
s.sel.MouseDownLineIdx = lineIdx
s.sel.MouseDownCol = startCol
s.sel.DragItemIdx = itemIdx
s.sel.DragLineIdx = lineIdx
s.sel.DragCol = endCol
}
// selectLine selects the entire line at the given position.
func (s *ScrollList) selectLine(itemIdx, lineIdx int) {
if itemIdx < 0 || itemIdx >= len(s.items) {
return
}
item := s.items[itemIdx]
content := item.Render(s.width)
lines := strings.Split(content, "\n")
if lineIdx < 0 || lineIdx >= len(lines) {
return
}
lineWidth := xansi.StringWidth(lines[lineIdx])
s.sel.MouseDown = true
s.sel.MouseDownItemIdx = itemIdx
s.sel.MouseDownLineIdx = lineIdx
s.sel.MouseDownCol = 0
s.sel.DragItemIdx = itemIdx
s.sel.DragLineIdx = lineIdx
s.sel.DragCol = lineWidth
}
// getItemAndLineAtY converts a viewport-relative Y coordinate to item index
// and line index within that item. Accounts for scroll offset and item gaps.
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
//
// IMPORTANT: Uses Render()+line counting (not Height()) to compute item height,
// because Height() on some MessageItem implementations (e.g. StreamingMessageItem
// for reasoning blocks) may return 0 when the render cache is empty.
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
if y < 0 || y >= s.height || len(s.items) == 0 {
return -1, -1
@@ -221,21 +296,27 @@ func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
currentY := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Compute height the same way View() does: render, then count lines.
itemHeight := s.renderedHeight(item)
// Account for partial visibility of the first item.
startLine := 0
if idx == s.offsetIdx {
startLine = s.offsetLine
itemHeight -= s.offsetLine
}
// Check if y falls within this item
if y >= currentY && y < currentY+itemHeight {
return idx, y - currentY
return idx, (y - currentY) + startLine
}
currentY += itemHeight
// Add gap after item (except last)
// Add gap after item (except last).
if s.itemGap > 0 && idx < len(s.items)-1 {
currentY += s.itemGap
}
// Stop if we've passed the viewport
if currentY >= s.height {
break
}
@@ -244,38 +325,9 @@ func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
return -1, -1
}
// HandleMouseUp handles mouse button release (crush-style).
// Finalizes selection and returns true if there was an active selection.
func (s *ScrollList) HandleMouseUp(x, y int) bool {
if !s.mouseDown {
return false
}
s.mouseDown = false
// Check if we have a valid selection
if s.selection.Active && !s.selection.IsEmpty() {
return true
}
return false
}
// GetSelection returns the current text selection.
func (s *ScrollList) GetSelection() CopySelection {
return s.selection
}
// ClearSelection clears the current text selection.
func (s *ScrollList) ClearSelection() {
s.selection = CopySelection{}
s.mouseDown = false
}
// HasSelection returns true if there is an active non-empty selection.
func (s *ScrollList) HasSelection() bool {
return s.selection.Active && !s.selection.IsEmpty()
}
// --------------------------------------------------------------------------
// Scrolling
// --------------------------------------------------------------------------
// ScrollBy scrolls the viewport by the given number of lines.
// Positive = scroll down, negative = scroll up.
@@ -361,14 +413,11 @@ func (s *ScrollList) GotoBottom() {
}
// Calculate total height including gaps
// Ensure items are rendered before checking height (iteratr pattern)
totalHeight := 0
for i, item := range s.items {
// Render to get actual content (handles non-cached items like reasoning blocks)
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
totalHeight += itemHeight
// Add gap after each item except the last
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
@@ -384,7 +433,6 @@ func (s *ScrollList) GotoBottom() {
// Otherwise, position viewport at bottom
remaining := totalHeight - s.height
for idx := 0; idx < len(s.items); idx++ {
// Render to get actual content
rendered := s.items[idx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if remaining < itemHeight {
@@ -393,7 +441,6 @@ func (s *ScrollList) GotoBottom() {
return
}
remaining -= itemHeight
// Subtract gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
remaining -= s.itemGap
}
@@ -416,12 +463,9 @@ func (s *ScrollList) AtBottom() bool {
return true
}
// Calculate visible height from current position including gaps
// Calculate height directly from rendered content (handles non-cached items)
visibleHeight := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
// Render to get actual content
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
@@ -431,7 +475,6 @@ func (s *ScrollList) AtBottom() bool {
visibleHeight += itemHeight
}
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
visibleHeight += s.itemGap
}
@@ -449,19 +492,28 @@ func (s *ScrollList) AtTop() bool {
return s.offsetIdx == 0 && s.offsetLine == 0
}
// --------------------------------------------------------------------------
// Rendering
// --------------------------------------------------------------------------
// View renders the visible portion of the scrollback.
// Only items that fit within the viewport height are rendered.
// ALWAYS returns exactly s.height lines (padded with empty lines if needed)
// to ensure the input/footer stay fixed at the bottom.
//
// When an active selection exists, character-level highlighting is applied
// using ultraviolet ScreenBuffer for ANSI-aware cell manipulation.
func (s *ScrollList) View() string {
if s.height <= 0 {
return ""
}
selRange := s.sel.GetRange()
hasSelection := !selRange.IsEmpty()
var lines []string
remainingHeight := s.height
// Render visible items
if len(s.items) > 0 {
for idx := s.offsetIdx; idx < len(s.items) && remainingHeight > 0; idx++ {
item := s.items[idx]
@@ -473,25 +525,22 @@ func (s *ScrollList) View() string {
startLine = s.offsetLine
}
// Check if this item is focused (for visual indicator)
isFocused := idx == s.focusedIdx
for i := startLine; i < len(contentLines) && remainingHeight > 0; i++ {
line := contentLines[i]
// Apply selection highlighting if this line is within selection
if s.selection.Active && s.isLineInSelection(idx, i) {
line = s.applyHighlight(line)
} else if isFocused && s.selectable {
// Apply subtle focus indicator when item is focused but not in selection
line = s.applyFocusIndicator(line)
// Apply character-level selection highlighting.
if hasSelection {
inRange, startCol, endCol := selection.IsLineInRange(selRange, idx, i)
if inRange {
line = selection.HighlightLine(line, startCol, endCol)
}
}
lines = append(lines, line)
remainingHeight--
}
// Add gap lines between items (but not after the last visible item)
// Add gap lines between items.
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
lines = append(lines, "")
@@ -501,8 +550,7 @@ func (s *ScrollList) View() string {
}
}
// Pad with empty lines to ensure exactly s.height lines
// This keeps the input/footer fixed at the bottom of the screen
// Pad with empty lines to ensure exactly s.height lines.
for remainingHeight > 0 {
lines = append(lines, "")
remainingHeight--
@@ -511,65 +559,6 @@ func (s *ScrollList) View() string {
return strings.Join(lines, "\n")
}
// isLineInSelection checks if a specific line within an item is part of the current selection.
func (s *ScrollList) isLineInSelection(itemIdx, lineIdx int) bool {
if !s.selection.Active {
return false
}
// Normalize selection (start <= end)
startItem := s.selection.StartItemIdx
startLine := s.selection.StartLine
endItem := s.selection.EndItemIdx
endLine := s.selection.EndLine
if startItem > endItem || (startItem == endItem && startLine > endLine) {
startItem, endItem = endItem, startItem
startLine, endLine = endLine, startLine
}
// Check if item is within selection range
if itemIdx < startItem || itemIdx > endItem {
return false
}
// For single item selection
if startItem == endItem {
return itemIdx == startItem && lineIdx >= startLine && lineIdx <= endLine
}
// For multi-item selection
if itemIdx == startItem {
return lineIdx >= startLine
}
if itemIdx == endItem {
return lineIdx <= endLine
}
// Middle items are fully selected
return itemIdx > startItem && itemIdx < endItem
}
// applyHighlight applies the highlight style to a line.
// Uses the theme's Highlight color for the background.
func (s *ScrollList) applyHighlight(line string) string {
if line == "" {
return line
}
// Apply background/foreground color change for selection
style := initHighlightStyle()
return style.Render(line)
}
// applyFocusIndicator applies a subtle visual indicator for focused items.
func (s *ScrollList) applyFocusIndicator(line string) string {
if line == "" {
return line
}
// Just return the line as-is - no visual indicator for focus
// The selection highlighting is enough
return line
}
// ScrollPercent returns the current scroll position as a percentage (0.0-1.0).
// 0.0 = at top, 1.0 = at bottom. Useful for scroll indicators.
func (s *ScrollList) ScrollPercent() float64 {
@@ -583,10 +572,9 @@ func (s *ScrollList) ScrollPercent() float64 {
}
if totalHeight <= s.height {
return 1.0 // All content fits, consider it "at bottom"
return 1.0
}
// Calculate how many lines are above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx && i < len(s.items); i++ {
linesAbove += s.items[i].Height()
@@ -609,8 +597,7 @@ func (s *ScrollList) ScrollPercent() float64 {
}
// clampOffset ensures the offset values are within valid bounds after
// resizing or scrolling operations. Prevents scrolling past the bottom
// of content (showing empty space when there's content above).
// resizing or scrolling operations.
func (s *ScrollList) clampOffset() {
if len(s.items) == 0 {
s.offsetIdx = 0
@@ -618,7 +605,6 @@ func (s *ScrollList) clampOffset() {
return
}
// First, clamp offsetIdx to valid item range
if s.offsetIdx >= len(s.items) {
s.offsetIdx = len(s.items) - 1
}
@@ -626,9 +612,7 @@ func (s *ScrollList) clampOffset() {
s.offsetIdx = 0
}
// Clamp offsetLine within current item
if s.offsetIdx < len(s.items) {
// Calculate height from rendered content (handles non-cached items)
rendered := s.items[s.offsetIdx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if s.offsetLine >= itemHeight {
@@ -639,8 +623,7 @@ func (s *ScrollList) clampOffset() {
s.offsetLine = 0
}
// Prevent scrolling past the bottom (showing empty space at bottom when there's content above)
// Calculate total content height
// Prevent scrolling past the bottom
totalHeight := 0
for i, item := range s.items {
rendered := item.Render(s.width)
@@ -650,14 +633,12 @@ func (s *ScrollList) clampOffset() {
}
}
// If content fits in viewport, force start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate how many lines are currently above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx; i++ {
rendered := s.items[i].Render(s.width)
@@ -668,13 +649,8 @@ func (s *ScrollList) clampOffset() {
}
linesAbove += s.offsetLine
// Calculate how many lines are visible from current position to end
linesFromCurrentToEnd := totalHeight - linesAbove
// If there's less content remaining than the viewport height,
// we've scrolled past the bottom - need to back up
if linesFromCurrentToEnd < s.height {
// Position viewport so the last line of content is at the bottom
targetLine := totalHeight - s.height
currentLine := 0
@@ -683,7 +659,6 @@ func (s *ScrollList) clampOffset() {
itemHeight := strings.Count(rendered, "\n") + 1
if currentLine+itemHeight > targetLine {
// This item contains the target line
s.offsetIdx = idx
s.offsetLine = targetLine - currentLine
return
@@ -696,3 +671,23 @@ func (s *ScrollList) clampOffset() {
}
}
}
// renderedHeight returns the height of a message item in lines by actually
// rendering it. This is the single source of truth for item height — it
// matches exactly what View() produces, unlike item.Height() which may
// return stale/zero values for uncached items (e.g. reasoning blocks).
func (s *ScrollList) renderedHeight(item MessageItem) int {
rendered := item.Render(s.width)
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
// abs returns the absolute value of x.
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
+324
View File
@@ -0,0 +1,324 @@
// Package selection provides character-level text selection for terminal UIs.
//
// It handles converting mouse coordinates (in terminal cells) to character
// positions within rendered ANSI-styled text, supporting multi-byte characters,
// wide characters (CJK, emoji), and word/line selection via double/triple click.
//
// The approach is modeled after Charm's crush: all coordinate calculations use
// display columns (terminal cells), not byte offsets or rune counts. The
// ultraviolet ScreenBuffer provides the bridge between rendered ANSI strings
// and individual character cells.
package selection
import (
"image"
"strings"
"time"
uv "github.com/charmbracelet/ultraviolet"
xansi "github.com/charmbracelet/x/ansi"
"github.com/clipperhouse/displaywidth"
"github.com/clipperhouse/uax29/v2/words"
)
// DoubleClickThreshold is the maximum time between clicks for multi-click.
const DoubleClickThreshold = 400 * time.Millisecond
// ClickTolerance is the pixel/cell tolerance for multi-click detection.
const ClickTolerance = 2
// State tracks the full state of a mouse text selection.
type State struct {
// Whether a mouse button is currently held down.
MouseDown bool
// Position where mouse was first pressed (viewport-relative).
MouseDownItemIdx int
MouseDownLineIdx int
MouseDownCol int
// Current drag position (viewport-relative).
DragItemIdx int
DragLineIdx int
DragCol int
// Multi-click detection.
LastClickTime time.Time
LastClickX int
LastClickY int
ClickCount int
}
// Range represents a normalized (start <= end) selection range.
type Range struct {
StartItemIdx int
StartLine int
StartCol int
EndItemIdx int
EndLine int
EndCol int
}
// IsEmpty returns true if the range selects nothing.
func (r Range) IsEmpty() bool {
return r.StartItemIdx < 0 || r.EndItemIdx < 0 ||
(r.StartItemIdx == r.EndItemIdx && r.StartLine == r.EndLine && r.StartCol == r.EndCol)
}
// NewState creates a new empty selection state.
func NewState() State {
return State{
MouseDownItemIdx: -1,
DragItemIdx: -1,
}
}
// Clear resets all selection state.
func (s *State) Clear() {
s.MouseDown = false
s.MouseDownItemIdx = -1
s.MouseDownLineIdx = 0
s.MouseDownCol = 0
s.DragItemIdx = -1
s.DragLineIdx = 0
s.DragCol = 0
s.LastClickTime = time.Time{}
s.LastClickX = 0
s.LastClickY = 0
s.ClickCount = 0
}
// HasSelection returns true if there is a non-empty active selection.
func (s *State) HasSelection() bool {
return s.MouseDownItemIdx >= 0 && s.DragItemIdx >= 0 && !s.GetRange().IsEmpty()
}
// GetRange returns the normalized selection range (start <= end).
func (s *State) GetRange() Range {
if s.MouseDownItemIdx < 0 || s.DragItemIdx < 0 {
return Range{StartItemIdx: -1, EndItemIdx: -1}
}
downItem := s.MouseDownItemIdx
downLine := s.MouseDownLineIdx
downCol := s.MouseDownCol
dragItem := s.DragItemIdx
dragLine := s.DragLineIdx
dragCol := s.DragCol
// Determine if dragging forward or backward.
forward := dragItem > downItem ||
(dragItem == downItem && dragLine > downLine) ||
(dragItem == downItem && dragLine == downLine && dragCol >= downCol)
if forward {
return Range{
StartItemIdx: downItem,
StartLine: downLine,
StartCol: downCol,
EndItemIdx: dragItem,
EndLine: dragLine,
EndCol: dragCol,
}
}
return Range{
StartItemIdx: dragItem,
StartLine: dragLine,
StartCol: dragCol,
EndItemIdx: downItem,
EndLine: downLine,
EndCol: downCol,
}
}
// IsLineInRange checks if a specific line within an item falls inside the
// selection range. Returns (inRange, startCol, endCol) where startCol == -1
// means the entire line is selected. startCol == endCol means no selection
// on this line.
func IsLineInRange(r Range, itemIdx, lineIdx int) (bool, int, int) {
if r.IsEmpty() {
return false, 0, 0
}
// Outside item range entirely.
if itemIdx < r.StartItemIdx || itemIdx > r.EndItemIdx {
return false, 0, 0
}
// Single-item selection.
if r.StartItemIdx == r.EndItemIdx {
if itemIdx != r.StartItemIdx {
return false, 0, 0
}
if lineIdx < r.StartLine || lineIdx > r.EndLine {
return false, 0, 0
}
if r.StartLine == r.EndLine {
// Single line: specific column range.
return true, r.StartCol, r.EndCol
}
if lineIdx == r.StartLine {
return true, r.StartCol, -1 // from startCol to end of line
}
if lineIdx == r.EndLine {
return true, 0, r.EndCol // from start of line to endCol
}
return true, -1, -1 // full line (middle of multi-line selection)
}
// Multi-item selection.
if itemIdx == r.StartItemIdx {
if lineIdx < r.StartLine {
return false, 0, 0
}
if lineIdx == r.StartLine {
return true, r.StartCol, -1
}
return true, -1, -1 // full line
}
if itemIdx == r.EndItemIdx {
if lineIdx > r.EndLine {
return false, 0, 0
}
if lineIdx == r.EndLine {
return true, 0, r.EndCol
}
return true, -1, -1 // full line
}
// Middle item: fully selected.
return true, -1, -1
}
// FindWordBoundaries finds the start and end column of the word at the given
// column position in a plain-text line (ANSI codes already stripped).
// Returns (startCol, endCol) where endCol is exclusive.
// Uses UAX#29 word segmentation and display-width-aware column tracking.
func FindWordBoundaries(line string, col int) (startCol, endCol int) {
if line == "" || col < 0 {
return 0, 0
}
// Segment the line into words using UAX#29.
lineCol := 0
iter := words.FromString(line)
for iter.Next() {
token := iter.Value()
tokenWidth := displaywidth.String(token)
graphemeStart := lineCol
graphemeEnd := lineCol + tokenWidth
lineCol += tokenWidth
// If clicked before this token, no word here.
if col < graphemeStart {
return col, col
}
// If clicked within this token, return its boundaries.
if col >= graphemeStart && col < graphemeEnd {
// Whitespace tokens produce empty selection.
if strings.TrimSpace(token) == "" {
return col, col
}
return graphemeStart, graphemeEnd
}
}
return col, col
}
// HighlightLine applies reverse-video highlighting to a portion of a rendered
// line (which may contain ANSI escape codes). startCol/endCol are in display
// columns. If startCol == -1, the entire line is highlighted. If startCol ==
// endCol, returns the line unchanged.
//
// Uses ultraviolet ScreenBuffer for cell-level ANSI manipulation.
func HighlightLine(line string, startCol, endCol int) string {
if line == "" {
return line
}
lineWidth := xansi.StringWidth(line)
if lineWidth == 0 {
return line
}
// Full-line highlight.
if startCol == -1 {
startCol = 0
endCol = lineWidth
}
if startCol >= endCol || startCol >= lineWidth {
return line
}
if endCol > lineWidth {
endCol = lineWidth
}
// Parse the styled line into a cell buffer.
area := image.Rect(0, 0, lineWidth, 1)
buf := uv.NewScreenBuffer(lineWidth, 1)
styled := uv.NewStyledString(line)
styled.Draw(&buf, area)
// Apply reverse attribute to cells in the selection range.
if buf.Height() > 0 {
bufLine := buf.Line(0)
for x := startCol; x < endCol && x < len(bufLine); x++ {
cell := bufLine.At(x)
if cell != nil {
cell.Style.Attrs |= uv.AttrReverse
}
}
}
return buf.Render()
}
// ExtractText extracts plain text from a rendered ANSI string within the given
// column range on a single line. Uses ultraviolet to parse ANSI and extract
// character content.
func ExtractText(line string, startCol, endCol int) string {
if line == "" {
return ""
}
lineWidth := xansi.StringWidth(line)
if lineWidth == 0 {
return ""
}
// Full-line extraction.
if startCol == -1 {
startCol = 0
endCol = lineWidth
}
if startCol >= endCol || startCol >= lineWidth {
return ""
}
if endCol > lineWidth {
endCol = lineWidth
}
// Parse to cell buffer.
area := image.Rect(0, 0, lineWidth, 1)
buf := uv.NewScreenBuffer(lineWidth, 1)
styled := uv.NewStyledString(line)
styled.Draw(&buf, area)
var sb strings.Builder
if buf.Height() > 0 {
bufLine := buf.Line(0)
for x := startCol; x < endCol && x < len(bufLine); x++ {
cell := bufLine.At(x)
if cell != nil && cell.Content != "" {
sb.WriteString(cell.Content)
}
}
}
return sb.String()
}
+400
View File
@@ -0,0 +1,400 @@
package selection
import (
"testing"
"time"
)
func TestNewState(t *testing.T) {
s := NewState()
if s.MouseDownItemIdx != -1 {
t.Errorf("expected MouseDownItemIdx -1, got %d", s.MouseDownItemIdx)
}
if s.DragItemIdx != -1 {
t.Errorf("expected DragItemIdx -1, got %d", s.DragItemIdx)
}
if s.MouseDown {
t.Error("expected MouseDown false")
}
if s.HasSelection() {
t.Error("expected no selection on new state")
}
}
func TestClear(t *testing.T) {
s := NewState()
s.MouseDown = true
s.MouseDownItemIdx = 2
s.DragItemIdx = 3
s.ClickCount = 2
s.Clear()
if s.MouseDown {
t.Error("expected MouseDown false after clear")
}
if s.MouseDownItemIdx != -1 {
t.Errorf("expected MouseDownItemIdx -1 after clear, got %d", s.MouseDownItemIdx)
}
if s.DragItemIdx != -1 {
t.Errorf("expected DragItemIdx -1 after clear, got %d", s.DragItemIdx)
}
if s.ClickCount != 0 {
t.Errorf("expected ClickCount 0 after clear, got %d", s.ClickCount)
}
}
func TestGetRange_Forward(t *testing.T) {
s := NewState()
s.MouseDownItemIdx = 0
s.MouseDownLineIdx = 1
s.MouseDownCol = 5
s.DragItemIdx = 0
s.DragLineIdx = 3
s.DragCol = 10
r := s.GetRange()
if r.StartItemIdx != 0 || r.StartLine != 1 || r.StartCol != 5 {
t.Errorf("unexpected start: item=%d line=%d col=%d", r.StartItemIdx, r.StartLine, r.StartCol)
}
if r.EndItemIdx != 0 || r.EndLine != 3 || r.EndCol != 10 {
t.Errorf("unexpected end: item=%d line=%d col=%d", r.EndItemIdx, r.EndLine, r.EndCol)
}
}
func TestGetRange_Backward(t *testing.T) {
s := NewState()
s.MouseDownItemIdx = 2
s.MouseDownLineIdx = 5
s.MouseDownCol = 20
s.DragItemIdx = 0
s.DragLineIdx = 1
s.DragCol = 3
r := s.GetRange()
// Should be normalized: drag position becomes start
if r.StartItemIdx != 0 || r.StartLine != 1 || r.StartCol != 3 {
t.Errorf("unexpected start: item=%d line=%d col=%d", r.StartItemIdx, r.StartLine, r.StartCol)
}
if r.EndItemIdx != 2 || r.EndLine != 5 || r.EndCol != 20 {
t.Errorf("unexpected end: item=%d line=%d col=%d", r.EndItemIdx, r.EndLine, r.EndCol)
}
}
func TestGetRange_SameLine(t *testing.T) {
s := NewState()
s.MouseDownItemIdx = 1
s.MouseDownLineIdx = 2
s.MouseDownCol = 10
s.DragItemIdx = 1
s.DragLineIdx = 2
s.DragCol = 20
r := s.GetRange()
if r.IsEmpty() {
t.Error("expected non-empty range")
}
if r.StartCol != 10 || r.EndCol != 20 {
t.Errorf("expected cols 10-20, got %d-%d", r.StartCol, r.EndCol)
}
}
func TestRangeIsEmpty(t *testing.T) {
// Same point
r := Range{StartItemIdx: 0, StartLine: 0, StartCol: 5, EndItemIdx: 0, EndLine: 0, EndCol: 5}
if !r.IsEmpty() {
t.Error("expected same-point range to be empty")
}
// Negative item idx
r = Range{StartItemIdx: -1, EndItemIdx: -1}
if !r.IsEmpty() {
t.Error("expected negative item idx range to be empty")
}
// Valid range
r = Range{StartItemIdx: 0, StartLine: 0, StartCol: 0, EndItemIdx: 0, EndLine: 0, EndCol: 5}
if r.IsEmpty() {
t.Error("expected valid range to not be empty")
}
}
func TestHasSelection(t *testing.T) {
s := NewState()
if s.HasSelection() {
t.Error("new state should have no selection")
}
// Set up a valid selection
s.MouseDownItemIdx = 0
s.MouseDownLineIdx = 0
s.MouseDownCol = 0
s.DragItemIdx = 0
s.DragLineIdx = 0
s.DragCol = 10
if !s.HasSelection() {
t.Error("expected selection to exist")
}
// Same point = no selection
s.DragCol = 0
if s.HasSelection() {
t.Error("same point should not be a selection")
}
}
func TestIsLineInRange_SingleItem_SingleLine(t *testing.T) {
r := Range{
StartItemIdx: 1, StartLine: 2, StartCol: 5,
EndItemIdx: 1, EndLine: 2, EndCol: 15,
}
// Exact line
ok, sc, ec := IsLineInRange(r, 1, 2)
if !ok || sc != 5 || ec != 15 {
t.Errorf("expected (true, 5, 15), got (%v, %d, %d)", ok, sc, ec)
}
// Wrong line
ok, _, _ = IsLineInRange(r, 1, 0)
if ok {
t.Error("line 0 should not be in range")
}
// Wrong item
ok, _, _ = IsLineInRange(r, 0, 2)
if ok {
t.Error("item 0 should not be in range")
}
}
func TestIsLineInRange_SingleItem_MultiLine(t *testing.T) {
r := Range{
StartItemIdx: 0, StartLine: 1, StartCol: 5,
EndItemIdx: 0, EndLine: 4, EndCol: 10,
}
// Start line
ok, sc, ec := IsLineInRange(r, 0, 1)
if !ok || sc != 5 || ec != -1 {
t.Errorf("start line: expected (true, 5, -1), got (%v, %d, %d)", ok, sc, ec)
}
// Middle line
ok, sc, ec = IsLineInRange(r, 0, 2)
if !ok || sc != -1 || ec != -1 {
t.Errorf("middle line: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
}
// End line
ok, sc, ec = IsLineInRange(r, 0, 4)
if !ok || sc != 0 || ec != 10 {
t.Errorf("end line: expected (true, 0, 10), got (%v, %d, %d)", ok, sc, ec)
}
}
func TestIsLineInRange_MultiItem(t *testing.T) {
r := Range{
StartItemIdx: 0, StartLine: 3, StartCol: 5,
EndItemIdx: 2, EndLine: 1, EndCol: 10,
}
// First item, start line
ok, sc, ec := IsLineInRange(r, 0, 3)
if !ok || sc != 5 || ec != -1 {
t.Errorf("first item start: expected (true, 5, -1), got (%v, %d, %d)", ok, sc, ec)
}
// First item, line after start
ok, sc, ec = IsLineInRange(r, 0, 5)
if !ok || sc != -1 || ec != -1 {
t.Errorf("first item after: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
}
// Middle item, any line
ok, sc, ec = IsLineInRange(r, 1, 0)
if !ok || sc != -1 || ec != -1 {
t.Errorf("middle item: expected (true, -1, -1), got (%v, %d, %d)", ok, sc, ec)
}
// Last item, end line
ok, sc, ec = IsLineInRange(r, 2, 1)
if !ok || sc != 0 || ec != 10 {
t.Errorf("last item end: expected (true, 0, 10), got (%v, %d, %d)", ok, sc, ec)
}
// Last item, line after end
ok, _, _ = IsLineInRange(r, 2, 5)
if ok {
t.Error("line after end in last item should not be in range")
}
}
func TestFindWordBoundaries(t *testing.T) {
tests := []struct {
name string
line string
col int
wantStart int
wantEnd int
}{
{
name: "simple word",
line: "hello world",
col: 2,
wantStart: 0,
wantEnd: 5,
},
{
name: "second word",
line: "hello world",
col: 7,
wantStart: 6,
wantEnd: 11,
},
{
name: "on space",
line: "hello world",
col: 5,
wantStart: 5,
wantEnd: 5,
},
{
name: "empty line",
line: "",
col: 0,
wantStart: 0,
wantEnd: 0,
},
{
name: "negative col",
line: "hello",
col: -1,
wantStart: 0,
wantEnd: 0,
},
{
name: "past end",
line: "hello",
col: 10,
wantStart: 10,
wantEnd: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end := FindWordBoundaries(tt.line, tt.col)
if start != tt.wantStart || end != tt.wantEnd {
t.Errorf("FindWordBoundaries(%q, %d) = (%d, %d), want (%d, %d)",
tt.line, tt.col, start, end, tt.wantStart, tt.wantEnd)
}
})
}
}
func TestExtractText_PlainText(t *testing.T) {
line := "Hello, World!"
text := ExtractText(line, 0, 5)
if text != "Hello" {
t.Errorf("expected 'Hello', got %q", text)
}
text = ExtractText(line, 7, 12)
if text != "World" {
t.Errorf("expected 'World', got %q", text)
}
}
func TestExtractText_FullLine(t *testing.T) {
line := "Hello"
text := ExtractText(line, -1, -1)
if text != "Hello" {
t.Errorf("expected 'Hello', got %q", text)
}
}
func TestExtractText_Empty(t *testing.T) {
text := ExtractText("", 0, 5)
if text != "" {
t.Errorf("expected empty string, got %q", text)
}
}
func TestExtractText_OutOfBounds(t *testing.T) {
line := "Hi"
text := ExtractText(line, 5, 10)
if text != "" {
t.Errorf("expected empty string for out of bounds, got %q", text)
}
}
func TestHighlightLine_PlainText(t *testing.T) {
line := "Hello, World!"
result := HighlightLine(line, 0, 5)
// Should produce a non-empty result different from input (has ANSI codes)
if result == "" {
t.Error("expected non-empty result")
}
// Should still contain the text content
if len(result) < len(line) {
t.Error("result should be at least as long as input (ANSI codes add length)")
}
}
func TestHighlightLine_Empty(t *testing.T) {
result := HighlightLine("", 0, 5)
if result != "" {
t.Errorf("expected empty for empty input, got %q", result)
}
}
func TestHighlightLine_NoSelection(t *testing.T) {
line := "Hello"
result := HighlightLine(line, 3, 3)
// Same startCol and endCol = no change
if result != line {
t.Errorf("expected no change for zero-width selection, got %q", result)
}
}
// TestMultiClickDetection verifies the click counting logic.
func TestMultiClickDetection(t *testing.T) {
s := NewState()
now := time.Now()
// First click
s.LastClickTime = now
s.LastClickX = 10
s.LastClickY = 5
s.ClickCount = 1
// Second click within threshold
later := now.Add(200 * time.Millisecond)
if later.Sub(s.LastClickTime) <= DoubleClickThreshold {
if abs(10-s.LastClickX) <= ClickTolerance && abs(5-s.LastClickY) <= ClickTolerance {
s.ClickCount++
}
}
if s.ClickCount != 2 {
t.Errorf("expected click count 2, got %d", s.ClickCount)
}
// Third click
s.LastClickTime = later
later2 := later.Add(200 * time.Millisecond)
if later2.Sub(s.LastClickTime) <= DoubleClickThreshold {
if abs(10-s.LastClickX) <= ClickTolerance && abs(5-s.LastClickY) <= ClickTolerance {
s.ClickCount++
}
}
if s.ClickCount != 3 {
t.Errorf("expected click count 3, got %d", s.ClickCount)
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
+136 -58
View File
@@ -12,6 +12,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/ui/style"
)
// SessionSelectedMsg is sent when the user selects a session from the picker.
@@ -158,12 +159,12 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
if ss.cursor > 0 {
ss.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
if ss.cursor < len(ss.filtered)-1 {
ss.cursor++
}
@@ -250,58 +251,108 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (ss *SessionSelectorComponent) View() tea.View {
theme := GetTheme()
w := ss.width
var b strings.Builder
theme := style.GetTheme()
// Full-screen bordered container - uses entire terminal width and height
maxWidth := ss.width - 2 // Small margin on each side
if maxWidth < 20 {
maxWidth = ss.width
}
maxHeight := ss.height - 2 // Small margin top/bottom to prevent overflow
if maxHeight < 10 {
maxHeight = ss.height
}
horizontalPadding := 1
innerWidth := maxWidth - 4 // Account for border (2) + padding (2)
innerHeight := maxHeight - 4 // Account for border (2) + padding (2)
// Container style with border - full width/height like a framed panel
containerStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.Primary).
Background(theme.Background).
Padding(1, horizontalPadding).
Width(maxWidth).
Height(maxHeight)
var contentBuilder strings.Builder
// ── Header: title + scope badges ─────────────────────────────
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).PaddingLeft(1)
b.WriteString(titleStyle.Render(fmt.Sprintf("Resume Session (%s)", ss.scope)))
b.WriteString("\n")
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(theme.Accent).
Background(theme.Background)
contentBuilder.WriteString(titleStyle.Render(fmt.Sprintf("Resume Session (%s)", ss.scope)))
contentBuilder.WriteString("\n")
// ── Help / keybindings ───────────────────────────────────────
helpStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(1)
if w >= 75 {
b.WriteString(helpStyle.Render("tab: scope N: named D: delete R: rename type to search esc: cancel"))
} else if w >= 50 {
b.WriteString(helpStyle.Render("tab scope N named D del type to search esc"))
helpStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
if innerWidth >= 75 {
contentBuilder.WriteString(helpStyle.Render("tab: scope N: named D: delete R: rename type to search esc: cancel"))
} else if innerWidth >= 50 {
contentBuilder.WriteString(helpStyle.Render("tab scope N named D del type to search esc"))
} else {
b.WriteString(helpStyle.Render("tab N D esc"))
contentBuilder.WriteString(helpStyle.Render("tab N D esc"))
}
b.WriteString("\n")
contentBuilder.WriteString("\n")
// ── Search (only shown when active) ──────────────────────────
if ss.search != "" {
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(1)
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ss.search)))
b.WriteString("\n")
searchStyle := lipgloss.NewStyle().
Foreground(theme.Info).
Background(theme.Background)
contentBuilder.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ss.search)))
contentBuilder.WriteString("\n")
}
b.WriteString("\n")
// Separator line
sepWidth := innerWidth
contentBuilder.WriteString(
lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background).
Render(strings.Repeat("─", sepWidth)))
contentBuilder.WriteString("\n")
// ── Delete confirmation ──────────────────────────────────────
if ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) {
warnStyle := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).PaddingLeft(1)
warnStyle := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Background(theme.Background)
name := sessionDisplayName(ss.filtered[ss.confirmDelete])
b.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40))))
b.WriteString("\n")
contentBuilder.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40))))
contentBuilder.WriteString("\n")
}
// ── Session list ─────────────────────────────────────────────
if len(ss.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
emptyStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
if ss.search != "" {
b.WriteString(emptyStyle.Render(fmt.Sprintf("No sessions matching %q", ss.search)))
contentBuilder.WriteString(emptyStyle.Render(fmt.Sprintf("No sessions matching %q", ss.search)))
} else if ss.filter == SessionFilterNamed {
b.WriteString(emptyStyle.Render("No named sessions. Press N to show all."))
contentBuilder.WriteString(emptyStyle.Render("No named sessions. Press N to show all."))
} else if ss.scope == SessionScopeCwd {
b.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all."))
contentBuilder.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all."))
} else {
b.WriteString(emptyStyle.Render("No sessions found"))
contentBuilder.WriteString(emptyStyle.Render("No sessions found"))
}
b.WriteString("\n")
contentBuilder.WriteString("\n")
} else {
visH := ss.visibleHeight()
// Compute visible window based on inner container height
// Chrome: header(2) + separator(1) + footer separator(1) + footer(1) = 5
chromeLines := 5
if ss.search != "" {
chromeLines++
}
if ss.confirmDelete >= 0 {
chromeLines++
}
visH := max(innerHeight-chromeLines, 3)
// Center the cursor in the visible window.
startIdx := max(0, min(ss.cursor-visH/2, len(ss.filtered)-visH))
@@ -312,20 +363,42 @@ func (ss *SessionSelectorComponent) View() tea.View {
isCursor := i == ss.cursor
isCurrent := info.Path == ss.currentPath
isDeleting := i == ss.confirmDelete
line := ss.renderEntry(info, isCursor, isCurrent, isDeleting, w)
b.WriteString(line)
b.WriteString("\n")
line := ss.renderEntry(info, isCursor, isCurrent, isDeleting, innerWidth)
contentBuilder.WriteString(line)
contentBuilder.WriteString("\n")
}
// Scroll position indicator.
if len(ss.filtered) > visH {
posStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(posStyle.Render(fmt.Sprintf("(%d/%d)", ss.cursor+1, len(ss.filtered))))
b.WriteString("\n")
posStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
contentBuilder.WriteString(posStyle.Render(fmt.Sprintf("(%d/%d)", ss.cursor+1, len(ss.filtered))))
contentBuilder.WriteString("\n")
}
}
return tea.NewView(b.String())
// Footer separator
contentBuilder.WriteString(
lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background).
Render(strings.Repeat("─", sepWidth)))
contentBuilder.WriteString("\n")
// Footer with filter info
footerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
contentBuilder.WriteString(footerStyle.Render(fmt.Sprintf("Filter: %s", ss.filter)))
// Apply the bordered container
content := contentBuilder.String()
borderedContent := containerStyle.Render(content)
v := tea.NewView(borderedContent)
v.AltScreen = true
return v
}
// IsActive returns whether the selector is still accepting input.
@@ -403,12 +476,12 @@ func removeByPath(sessions []session.SessionInfo, path string) []session.Session
// renderEntry renders a single session line with right-aligned metadata.
// Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?]
func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string {
theme := GetTheme()
theme := style.GetTheme()
// ── Cursor indicator (2 chars) ───────────────────────────────
cursorStr := " "
if isCursor {
cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render(" ")
cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render("> ")
}
const cursorW = 2
@@ -436,45 +509,50 @@ func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCurs
msgW := utf8.RuneCountInString(displayText)
// ── Style the message ────────────────────────────────────────
msgStyle := lipgloss.NewStyle()
var msgStyle lipgloss.Style
switch {
case isDeleting:
msgStyle = msgStyle.Foreground(theme.Error)
msgStyle = lipgloss.NewStyle().Foreground(theme.Error)
case isCurrent:
msgStyle = msgStyle.Foreground(theme.Accent)
msgStyle = lipgloss.NewStyle().Foreground(theme.Accent)
case info.Name != "":
msgStyle = msgStyle.Foreground(theme.Warning)
msgStyle = lipgloss.NewStyle().Foreground(theme.Warning)
default:
msgStyle = msgStyle.Foreground(theme.Text)
msgStyle = lipgloss.NewStyle().Foreground(theme.Text)
}
if isCursor {
msgStyle = msgStyle.Bold(true)
}
styledMsg := msgStyle.Render(displayText)
// ── Style the right part ─────────────────────────────────────
rightColor := theme.Muted
if isDeleting {
rightColor = theme.Error
}
styledRight := lipgloss.NewStyle().Foreground(rightColor).Render(rightPart)
var styledRight string
// ── Assemble with spacing ────────────────────────────────────
spacing := max(width-cursorW-msgW-rightW, 1)
line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight
// ── Background highlight for selected row ────────────────────
// If selected, use inverted colors like PopupList
if isCursor {
// Use a subtle background highlight. We apply it by wrapping the
// full line in a style with a background color.
bgStyle := lipgloss.NewStyle().
Background(theme.Highlight).
Width(width)
line = bgStyle.Render(line)
// Inverted colors for selected item
msgStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Background).
Bold(true)
styledRight = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(rightColor).
Render(rightPart)
cursorStr = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Accent).
Render("> ")
} else {
styledRight = lipgloss.NewStyle().Foreground(rightColor).Render(rightPart)
}
styledMsg := msgStyle.Render(displayText)
line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight
return line
}
+18 -57
View File
@@ -2,25 +2,15 @@ package ui
import (
"fmt"
"regexp"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"github.com/mark3labs/kit/internal/app"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from streaming text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// thinkTagOpen and thinkTagClose are the opening and closing think tag strings.
const (
thinkTagOpen = "<think>"
thinkTagClose = "</think>"
"github.com/mark3labs/kit/internal/ui/style"
)
// knightRiderFrames generates a KITT-style scanning animation where a bright
@@ -31,7 +21,7 @@ func knightRiderFrames() []string {
const numDots = 8
const dot = "▪"
theme := GetTheme()
theme := style.GetTheme()
bright := lipgloss.NewStyle().Foreground(theme.Primary)
med := lipgloss.NewStyle().Foreground(theme.Muted)
@@ -205,10 +195,6 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// inThinkTag tracks whether we're currently inside a section
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
inThinkTag bool
// renderer renders streaming assistant text.
renderer Renderer
@@ -317,9 +303,7 @@ func (s *StreamComponent) GetRenderedContent() string {
// Called before reading content for output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
s.streamContent.WriteString(cleanedText)
s.streamContent.WriteString(s.pendingStream.String())
s.pendingStream.Reset()
}
if s.pendingReasoning.Len() > 0 {
@@ -399,6 +383,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.ReasoningCompleteEvent:
// Freeze reasoning duration when reasoning finishes (before text streaming starts).
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
// Flush any remaining pending reasoning content.
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
}
case app.StreamChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
@@ -409,43 +404,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
// Handle models that wrap reasoning in tags (Qwen, DeepSeek)
// Filter out all content between and tags
content := msg.Content
// Check for opening tag
if strings.Contains(content, thinkTagOpen) {
parts := strings.SplitN(content, thinkTagOpen, 2)
// Content before the tag can be written
if !s.inThinkTag && parts[0] != "" {
s.pendingStream.WriteString(parts[0])
}
s.inThinkTag = true
// Content after the opening tag is reasoning - don't write it
if len(parts) > 1 && parts[1] != "" {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], thinkTagClose) {
innerParts := strings.SplitN(parts[1], thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(innerParts) > 1 && innerParts[1] != "" {
s.pendingStream.WriteString(innerParts[1])
}
}
}
} else if strings.Contains(content, thinkTagClose) {
// Closing tag found
parts := strings.SplitN(content, thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(parts) > 1 && parts[1] != "" {
s.pendingStream.WriteString(parts[1])
}
} else if !s.inThinkTag {
// Normal content, not inside think tags
s.pendingStream.WriteString(content)
}
// else: inside think tag, don't write this content
// <think> tag filtering is handled at the agent layer — chunks here
// are already clean text.
s.pendingStream.WriteString(msg.Content)
if !s.flushPending && s.pendingStream.Len() > 0 {
s.flushPending = true
@@ -1,4 +1,4 @@
package ui
package style
import (
"fmt"
@@ -301,13 +301,13 @@ func KitBanner() string {
kittDark := lipgloss.Color("#8B0000")
kittBright := lipgloss.Color("#FF2200")
lines := []string{
" ██╗ ██╗ ██╗ ████████╗",
" ██║ ██╔╝ ██║ ╚══██╔══╝",
" █████╔╝ ██║ ██║",
" ██╔═██╗ ██║ ██║",
" ██║ ██╗ ██║ ██║",
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
" ░░░░░░▒▒▒▒▓▓▓▓███████████████▓▓▓▓▒▒▒▒░░░░░░",
" ██╗ ██╗ ██╗ ████████╗",
" ██║ ██╔╝ ██║ ╚══██╔══╝",
" █████╔╝ ██║ ██║",
" ██╔═██╗ ██║ ██║",
" ██║ ██╗ ██║ ██║",
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
"░░ ░░ ░░ ▒▒ ▒▒ ▓▓ ▓▓ ████ ▓▓ ▓▓ ▒▒ ▒▒ ░░ ░░ ░░",
}
var result strings.Builder
@@ -1,4 +1,4 @@
package ui
package style
import (
"charm.land/lipgloss/v2"
@@ -85,10 +85,10 @@ func GetMarkdownTypography() *herald.Typography {
return ty
}
// toMarkdown renders markdown content using herald-md.
// ToMarkdown renders markdown content using herald-md.
// The width parameter is currently unused as herald handles wrapping
// based on terminal width internally.
func toMarkdown(content string, width int) string {
func ToMarkdown(content string, width int) string {
ty := GetMarkdownTypography()
rendered := heraldmd.Render(ty, []byte(content))
return rendered
@@ -1,4 +1,4 @@
package ui
package style
import (
"encoding/json"
@@ -11,6 +11,8 @@ import (
"strings"
"gopkg.in/yaml.v3"
"github.com/mark3labs/kit/internal/ui/prefs"
)
// ---------------------------------------------------------------------------
@@ -410,10 +412,10 @@ func initThemeRegistry() {
}
// 2. User themes from ~/.config/kit/themes/
scanThemesDir(userThemesDir())
scanThemesDir(UserThemesDir())
// 3. Project-local themes from .kit/themes/
scanThemesDir(projectThemesDir())
scanThemesDir(ProjectThemesDir())
sortRegistry()
}
@@ -461,7 +463,7 @@ func removeFromRegistry(name string) {
}
// userThemesDir returns ~/.config/kit/themes, creating it if needed.
func userThemesDir() string {
func UserThemesDir() string {
cfgDir, err := os.UserConfigDir()
if err != nil {
return ""
@@ -473,7 +475,7 @@ func userThemesDir() string {
// projectThemesDir returns .kit/themes/ relative to the working directory.
// Returns "" if the directory doesn't exist (does NOT create it).
func projectThemesDir() string {
func ProjectThemesDir() string {
dir := filepath.Join(".kit", "themes")
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
@@ -525,7 +527,7 @@ func ApplyTheme(name string) error {
return err
}
SetTheme(t)
_ = SaveThemePreference(name)
_ = prefs.SaveThemePreference(name)
return nil
}
@@ -1,4 +1,4 @@
package ui
package style
import (
"testing"
+277 -32
View File
@@ -28,10 +28,10 @@ const (
maxLsLines = 20 // lines for Ls directory listings
)
// isShellTool reports if the tool name matches a shell-like tool (bash, grep, find, or
// isShellTool reports if the tool name matches a shell-like tool (bash or
// tools with "shell"/"command" in the name). Used by renderToolBody.
func isShellTool(toolName string) bool {
return toolName == "bash" || toolName == "grep" || toolName == "find" ||
return toolName == "bash" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command")
}
@@ -55,8 +55,16 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderWriteBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "find":
if body := renderFindBody(toolResult, width); body != "" {
return body
}
case toolName == "grep":
if body := renderGrepBody(toolResult, width); body != "" {
return body
}
case isShellTool(toolName):
if body := renderBashBody(toolResult, width); body != "" {
if body := renderBashBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "subagent":
@@ -337,6 +345,148 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
// Ls tool — simple list without gutter
// ---------------------------------------------------------------------------
// renderFindBody renders find output as a plain list with code background.
// Similar to ls but with results-specific caption.
func renderFindBody(toolResult string, width int) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalResults := len(lines)
// Truncate to maxLsLines for display
var hiddenCount int
if len(lines) > maxLsLines {
hiddenCount = len(lines) - maxLsLines
lines = lines[:maxLsLines]
}
const lineIndent = " "
codeWidth := max(width-len(lineIndent), 20)
theme := GetTheme()
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var rendered []string
for _, line := range lines {
// Truncate before styling to prevent wrapping.
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
styled := codeStyle.Width(codeWidth).Render(line)
rendered = append(rendered, styled)
}
content = strings.Join(rendered, "\n")
// Build caption with results info
var captionParts []string
if totalResults == 1 {
captionParts = append(captionParts, "1 result")
} else {
captionParts = append(captionParts, fmt.Sprintf("%d results", totalResults))
}
if hiddenCount > 0 {
captionParts = append(captionParts, fmt.Sprintf("%d more", hiddenCount))
}
if len(captionParts) > 1 || hiddenCount > 0 {
ty := herald.New(herald.WithTheme(herald.Theme{
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
FigureCaptionPosition: herald.CaptionBottom,
}))
caption := strings.Join(captionParts, " • ")
result := ty.Figure(content, caption)
// Indent entire block (content + caption) to match other tools
const blockIndent = " "
resultLines := strings.Split(result, "\n")
for i, line := range resultLines {
resultLines[i] = blockIndent + line
}
return strings.Join(resultLines, "\n")
}
// Single result with no truncation - just return indented content
const blockIndent = " "
contentLines := strings.Split(content, "\n")
for i, line := range contentLines {
contentLines[i] = blockIndent + line
}
return strings.Join(contentLines, "\n")
}
// renderGrepBody renders grep output as a plain list with code background.
// Similar to find but with match-specific caption terminology.
func renderGrepBody(toolResult string, width int) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalMatches := len(lines)
// Truncate to maxLsLines for display
var hiddenCount int
if len(lines) > maxLsLines {
hiddenCount = len(lines) - maxLsLines
lines = lines[:maxLsLines]
}
const lineIndent = " "
codeWidth := max(width-len(lineIndent), 20)
theme := GetTheme()
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var rendered []string
for _, line := range lines {
// Truncate before styling to prevent wrapping.
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
styled := codeStyle.Width(codeWidth).Render(line)
rendered = append(rendered, styled)
}
content = strings.Join(rendered, "\n")
// Build caption with match info
var captionParts []string
if totalMatches == 1 {
captionParts = append(captionParts, "1 match")
} else {
captionParts = append(captionParts, fmt.Sprintf("%d matches", totalMatches))
}
if hiddenCount > 0 {
captionParts = append(captionParts, fmt.Sprintf("%d more", hiddenCount))
}
if len(captionParts) > 1 || hiddenCount > 0 {
ty := herald.New(herald.WithTheme(herald.Theme{
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
FigureCaptionPosition: herald.CaptionBottom,
}))
caption := strings.Join(captionParts, " • ")
result := ty.Figure(content, caption)
// Indent entire block (content + caption) to match other tools
const blockIndent = " "
resultLines := strings.Split(result, "\n")
for i, line := range resultLines {
resultLines[i] = blockIndent + line
}
return strings.Join(resultLines, "\n")
}
// Single match with no truncation - just return indented content
const blockIndent = " "
contentLines := strings.Split(content, "\n")
for i, line := range contentLines {
contentLines[i] = blockIndent + line
}
return strings.Join(contentLines, "\n")
}
// renderLsBody renders ls output as a plain list with code background and no
// line-number gutter.
func renderLsBody(toolResult string, width int) string {
@@ -354,28 +504,47 @@ func renderLsBody(toolResult string, width int) string {
lines = lines[:maxLsLines]
}
const indent = " "
codeWidth := max(width-len(indent), 20)
const lineIndent = " "
codeWidth := max(width-len(lineIndent), 20)
theme := GetTheme()
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var result []string
var rendered []string
for _, line := range lines {
// Truncate before styling to prevent wrapping.
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
styled := codeStyle.Width(codeWidth).Render(line)
result = append(result, indent+styled)
rendered = append(rendered, styled)
}
content = strings.Join(rendered, "\n")
// Build caption with hidden entries info
if hiddenCount > 0 {
hint := fmt.Sprintf("...(%d more entries)", hiddenCount)
hintContent := codeStyle.Width(codeWidth).
Foreground(theme.Muted).Italic(true).Render(hint)
result = append(result, indent+hintContent)
ty := herald.New(herald.WithTheme(herald.Theme{
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
FigureCaptionPosition: herald.CaptionBottom,
}))
caption := fmt.Sprintf("%d more entries", hiddenCount)
result := ty.Figure(content, caption)
// Indent entire block (content + caption) to match other tools
const blockIndent = " "
resultLines := strings.Split(result, "\n")
for i, line := range resultLines {
resultLines[i] = blockIndent + line
}
return strings.Join(resultLines, "\n")
}
return strings.Join(result, "\n")
// No caption - just return indented content
const blockIndent = " "
contentLines := strings.Split(content, "\n")
for i, line := range contentLines {
contentLines[i] = blockIndent + line
}
return strings.Join(contentLines, "\n")
}
// ---------------------------------------------------------------------------
@@ -461,19 +630,50 @@ func renderReadBody(toolArgs, toolResult string, width int) string {
)
// Render the code block
result := ty.CodeBlock(codeContent, lang)
codeBlock := ty.CodeBlock(codeContent, lang)
// Add truncation hint if needed
// Herald's codeBlockWithLineNumbers() hardcodes PaddingTop(1) and
// PaddingBottom(1), adding invisible blank lines with background color
// above and below the code. These interfere with mouse selection
// (off-by-one) because the padding line looks blank but occupies a
// line index in the rendered item. Strip them since the Compose
// separator above and Figure caption below already provide spacing.
codeBlock = stripCodeBlockPadding(codeBlock)
// Parse total lines from footer if available (e.g., "[showing lines 1-100 of 407 total...]")
totalLines := totalCodeLines
for _, footer := range footerLines {
if matches := regexp.MustCompile(`of (\d+) total`).FindStringSubmatch(footer); len(matches) > 1 {
if t, _ := strconv.Atoi(matches[1]); t > totalLines {
totalLines = t
}
}
}
// Build caption with file metadata
var captionParts []string
if fileName != "" {
captionParts = append(captionParts, filepath.Base(fileName))
}
if len(codeLines) > 0 {
endLine := offset + len(codeLines) - 1
captionParts = append(captionParts, fmt.Sprintf("lines %d-%d of %d", offset, endLine, totalLines))
}
if codeHiddenCount > 0 {
hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount)
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Italic(true).Render(hint)
nextOffset := offset + len(codeLines)
captionParts = append(captionParts, fmt.Sprintf("offset=%d to continue", nextOffset))
}
// Add any footer lines
if len(footerLines) > 0 {
footer := strings.Join(footerLines, "\n")
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Render(footer)
caption := strings.Join(captionParts, " • ")
// Use Figure with caption below content (default behavior)
// Apply theme to ensure caption is positioned below
figTheme := herald.Theme{
FigureCaption: lipgloss.NewStyle().Foreground(GetTheme().Muted),
FigureCaptionPosition: herald.CaptionBottom,
}
tyFig := herald.New(herald.WithTheme(figTheme))
result := tyFig.Figure(codeBlock, caption)
// Indent entire block to match Write/Edit tools (2 spaces)
const blockIndent = " "
@@ -582,7 +782,7 @@ func renderWriteBlock(content, fileName string, width int) string {
// renderBashBody renders bash output with per-line background and stderr
// in error color.
func renderBashBody(toolResult string, width int) string {
func renderBashBody(toolArgs, toolResult string, width int) string {
if strings.TrimSpace(toolResult) == "" {
return ""
}
@@ -609,6 +809,7 @@ func renderBashBody(toolResult string, width int) string {
maxLineChars := lineWidth - 1
var rendered []string
exitCode := -1 // -1 means not found
inStderr := false
for _, line := range lines {
line = truncateLine(line, maxLineChars)
@@ -617,30 +818,55 @@ func renderBashBody(toolResult string, width int) string {
inStderr = true
continue
}
// Exit code line
// Exit code line - extract it for caption
if strings.HasPrefix(line, "Exit code:") {
styled := stderrStyle.Width(width - len(lineIndent)).Render(line)
rendered = append(rendered, lineIndent+styled)
continue
_, _ = fmt.Sscanf(line, "Exit code: %d", &exitCode)
continue // Don't render exit code inline, it goes in caption
}
if inStderr {
styled := stderrStyle.Width(width - len(lineIndent)).Render(line)
rendered = append(rendered, lineIndent+styled)
rendered = append(rendered, styled)
} else {
styled := outputStyle.Width(width - len(lineIndent)).Render(line)
rendered = append(rendered, lineIndent+styled)
rendered = append(rendered, styled)
}
}
// Build caption with status info
var captionParts []string
if hiddenCount > 0 {
truncMsg := fmt.Sprintf("...(%d more lines)", hiddenCount)
hint := outputStyle.Width(width - len(lineIndent)).
Foreground(theme.Muted).Italic(true).Render(truncMsg)
rendered = append(rendered, lineIndent+hint)
captionParts = append(captionParts, fmt.Sprintf("%d more lines", hiddenCount))
}
if exitCode >= 0 {
captionParts = append(captionParts, fmt.Sprintf("exit code %d", exitCode))
}
return strings.Join(rendered, "\n")
content := strings.Join(rendered, "\n")
if len(captionParts) > 0 {
ty := herald.New(herald.WithTheme(herald.Theme{
FigureCaption: lipgloss.NewStyle().Foreground(theme.Muted),
FigureCaptionPosition: herald.CaptionBottom,
}))
caption := strings.Join(captionParts, " • ")
result := ty.Figure(content, caption)
// Indent entire block (content + caption) to match other tools
const blockIndent = " "
lines := strings.Split(result, "\n")
for i, line := range lines {
lines[i] = blockIndent + line
}
return strings.Join(lines, "\n")
}
// No caption - just return indented content
const blockIndent = " "
contentLines := strings.Split(content, "\n")
for i, line := range contentLines {
contentLines[i] = blockIndent + line
}
return strings.Join(contentLines, "\n")
}
// ---------------------------------------------------------------------------
@@ -724,6 +950,25 @@ func padRight(s string, width int) string {
return s + strings.Repeat(" ", width-w)
}
// stripCodeBlockPadding removes the top and bottom padding lines that herald's
// codeBlockWithLineNumbers() hardcodes via PaddingTop(1)/PaddingBottom(1).
// These padding lines are blank lines with background color that look invisible
// but occupy line indices, causing mouse selection to be off by one row.
func stripCodeBlockPadding(block string) string {
lines := strings.Split(block, "\n")
if len(lines) < 3 {
return block
}
// The first and last lines are padding (blank with bg color).
// Strip them only if they contain no visible text.
first := xansi.Strip(lines[0])
last := xansi.Strip(lines[len(lines)-1])
if strings.TrimSpace(first) == "" && strings.TrimSpace(last) == "" {
return strings.Join(lines[1:len(lines)-1], "\n")
}
return block
}
// truncateLine truncates a line to maxWidth visual characters, adding "…"
// if truncated. This is ANSI-aware: escape codes are preserved and wide
// characters are measured correctly.
+179 -55
View File
@@ -10,6 +10,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/ui/core"
)
// TreeFilterMode controls which entries are visible in the tree selector.
@@ -88,6 +89,28 @@ func NewTreeSelector(tm *session.TreeManager, width, height int) *TreeSelectorCo
return ts
}
// NewTreeSelectorForFork creates a tree selector for the /fork command.
// It shows only user messages (flat list) matching Pi's fork behavior.
func NewTreeSelectorForFork(tm *session.TreeManager, width, height int) *TreeSelectorComponent {
ts := &TreeSelectorComponent{
tm: tm,
filter: TreeFilterUserOnly,
leafID: tm.GetLeafID(),
width: width,
height: height,
active: true,
}
ts.rebuildFlatList()
// Position cursor at the last user message before the leaf.
for i := len(ts.flatNodes) - 1; i >= 0; i-- {
if ts.isUserMessage(ts.flatNodes[i].Entry) {
ts.cursor = i
break
}
}
return ts
}
// Init implements tea.Model.
func (ts *TreeSelectorComponent) Init() tea.Cmd {
return nil
@@ -103,12 +126,12 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
if ts.cursor > 0 {
ts.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
if ts.cursor < len(ts.flatNodes)-1 {
ts.cursor++
}
@@ -138,7 +161,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ts.selectedID = ts.flatNodes[ts.cursor].ID
ts.active = false
return ts, func() tea.Msg {
return TreeNodeSelectedMsg{
return core.TreeNodeSelectedMsg{
ID: ts.selectedID,
Entry: ts.flatNodes[ts.cursor].Entry,
IsUser: ts.isUserMessage(ts.flatNodes[ts.cursor].Entry),
@@ -155,7 +178,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ts.cancelled = true
ts.active = false
return ts, func() tea.Msg {
return TreeCancelledMsg{}
return core.TreeCancelledMsg{}
}
}
@@ -203,46 +226,92 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (ts *TreeSelectorComponent) View() tea.View {
theme := GetTheme()
// Full-screen bordered container - uses entire terminal width and height
maxWidth := ts.width - 2 // Small margin on each side
if maxWidth < 20 {
maxWidth = ts.width
}
maxHeight := ts.height - 2 // Small margin top/bottom to prevent overflow
if maxHeight < 10 {
maxHeight = ts.height
}
horizontalPadding := 1
innerWidth := maxWidth - 4 // Account for border (2) + padding (2)
innerHeight := maxHeight - 4 // Account for border (2) + padding (2)
// Container style with border - full width/height like a framed panel
containerStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.Primary).
Background(theme.Background).
Padding(1, horizontalPadding).
Width(maxWidth).
Height(maxHeight)
// Header style with background highlight (like PopupList title)
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(theme.Accent).
PaddingLeft(2)
Background(theme.Background)
// Help text style
helpStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
PaddingLeft(2)
Background(theme.Background)
var b strings.Builder
var contentBuilder strings.Builder
// Header.
b.WriteString(headerStyle.Render("Session Tree"))
b.WriteString("\n")
// Adapt help text to terminal width.
// Header row with title and help
headerRow := headerStyle.Render("Session Tree")
contentBuilder.WriteString(headerRow)
contentBuilder.WriteString("\n")
// Help text - adapt to terminal width
var helpText string
if ts.width >= 70 {
b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"))
helpText = "↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"
} else if ts.width >= 45 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc cancel ^O filter"))
helpText = "↑↓ move ↵ select esc cancel ^O filter"
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc ^O"))
helpText = "↑↓ ↵ esc ^O"
}
b.WriteString("\n")
contentBuilder.WriteString(helpStyle.Render(helpText))
contentBuilder.WriteString("\n")
// Search display (if active)
if ts.search != "" {
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(2)
b.WriteString(searchStyle.Render(fmt.Sprintf("Search: %s", ts.search)))
b.WriteString("\n")
searchStyle := lipgloss.NewStyle().
Foreground(theme.Info).
Background(theme.Background)
contentBuilder.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ts.search)))
contentBuilder.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ts.width)))
b.WriteString("\n")
// Separator line - full width
sepWidth := innerWidth
contentBuilder.WriteString(
lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background).
Render(strings.Repeat("─", sepWidth)))
contentBuilder.WriteString("\n")
// Tree content
if len(ts.flatNodes) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(emptyStyle.Render("No entries in session"))
b.WriteString("\n")
emptyStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
contentBuilder.WriteString(emptyStyle.Render("No entries in session"))
contentBuilder.WriteString("\n")
} else {
// Compute visible window.
visH := ts.visibleHeight()
// Compute visible window based on inner container height
// Chrome: header(2) + separator(1) + footer separator(1) + footer(1) = 5
chromeLines := 5
if ts.search != "" {
chromeLines++
}
visH := max(innerHeight-chromeLines, 3)
startIdx := 0
if ts.cursor >= visH {
startIdx = ts.cursor - visH + 1
@@ -251,21 +320,34 @@ func (ts *TreeSelectorComponent) View() tea.View {
for i := startIdx; i < endIdx; i++ {
node := ts.flatNodes[i]
line := ts.renderNode(node, i == ts.cursor, node.ID == ts.leafID)
b.WriteString(line)
b.WriteString("\n")
line := ts.renderNode(node, i == ts.cursor, node.ID == ts.leafID, innerWidth)
contentBuilder.WriteString(line)
contentBuilder.WriteString("\n")
}
}
// Footer.
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ts.width)))
b.WriteString("\n")
// Footer separator
contentBuilder.WriteString(
lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background).
Render(strings.Repeat("─", sepWidth)))
contentBuilder.WriteString("\n")
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
// Footer with count and filter
footerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.Background)
footer := fmt.Sprintf("(%d/%d) [%s]", ts.cursor+1, len(ts.flatNodes), ts.filter)
b.WriteString(footerStyle.Render(footer))
contentBuilder.WriteString(footerStyle.Render(footer))
return tea.NewView(b.String())
// Apply the bordered container - full width, no centering
content := contentBuilder.String()
borderedContent := containerStyle.Render(content)
v := tea.NewView(borderedContent)
v.AltScreen = true
return v
}
// IsActive returns whether the tree selector is still accepting input.
@@ -395,21 +477,23 @@ func (ts *TreeSelectorComponent) passesFilter(node *session.TreeNode) bool {
}
}
func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool) string {
func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool, innerWidth int) string {
theme := GetTheme()
maxWidth := max(ts.width-4, 10)
// Cursor indicator.
// Cursor indicator - use ">" for selected (like PopupList)
var cursor string
if isCursor {
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render(" ")
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("> ")
} else {
cursor = " "
}
// Role-colored content.
// Role-colored content with background support for selection
text := ts.entryDisplayText(node.Entry)
available := maxWidth - len(node.Prefix) - 10
// Calculate available width accounting for cursor, prefix, and markers
prefixLen := len(node.Prefix)
available := innerWidth - prefixLen - 4 // 4 for cursor and some padding
if available > 3 && len(text) > available {
trimLen := max(available-3, 1)
if trimLen < len(text) {
@@ -417,48 +501,88 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool
}
}
var style lipgloss.Style
// Build the full line style
var lineStyle lipgloss.Style
var textStyle lipgloss.Style
// Base text color based on role
switch e := node.Entry.(type) {
case *session.MessageEntry:
switch e.Role {
case "user":
style = lipgloss.NewStyle().Foreground(theme.Accent)
textStyle = lipgloss.NewStyle().Foreground(theme.Accent)
case "assistant":
style = lipgloss.NewStyle().Foreground(theme.Success)
textStyle = lipgloss.NewStyle().Foreground(theme.Success)
default:
style = lipgloss.NewStyle().Foreground(theme.Muted)
textStyle = lipgloss.NewStyle().Foreground(theme.Muted)
}
case *session.BranchSummaryEntry:
style = lipgloss.NewStyle().Foreground(theme.Warning).Italic(true)
textStyle = lipgloss.NewStyle().Foreground(theme.Warning).Italic(true)
case *session.CompactionEntry:
style = lipgloss.NewStyle().Foreground(theme.Info).Italic(true)
textStyle = lipgloss.NewStyle().Foreground(theme.Info).Italic(true)
default:
style = lipgloss.NewStyle().Foreground(theme.Muted)
textStyle = lipgloss.NewStyle().Foreground(theme.Muted)
}
// Apply selection highlighting (like PopupList)
if isCursor {
style = style.Bold(true)
// Inverted colors for selected item - matches PopupList style
lineStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Background).
Bold(true)
textStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Background).
Bold(true)
}
content := style.Render(text)
// Render components
content := textStyle.Render(text)
// Label badge.
var labelBadge string
if node.Label != "" {
labelBadge = " " + lipgloss.NewStyle().Foreground(theme.Warning).Render("["+node.Label+"]")
labelStyle := lipgloss.NewStyle().Foreground(theme.Warning)
if isCursor {
labelStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Warning)
}
labelBadge = " " + labelStyle.Render("["+node.Label+"]")
}
// Active marker.
// Active marker - use Success color for better visibility
var activeMarker string
if isLeaf {
activeMarker = lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render(" ← active")
markerStyle := lipgloss.NewStyle().Foreground(theme.Success).Bold(true)
if isCursor {
markerStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.Success).
Bold(true)
}
activeMarker = markerStyle.Render(" ← active")
}
// Prefix (tree lines).
prefixStyle := lipgloss.NewStyle().Foreground(theme.Muted)
// Prefix (tree lines) - use MutedBorder for subtler appearance
prefixStyle := lipgloss.NewStyle().Foreground(theme.MutedBorder)
if isCursor {
prefixStyle = lipgloss.NewStyle().
Background(theme.Primary).
Foreground(theme.MutedBorder)
}
renderedPrefix := prefixStyle.Render(node.Prefix)
return cursor + renderedPrefix + content + labelBadge + activeMarker
// Combine all parts
line := cursor + renderedPrefix + content + labelBadge + activeMarker
// If selected, apply the background to the entire line
if isCursor {
return lineStyle.Render(line)
}
return line
}
func (ts *TreeSelectorComponent) entryDisplayText(entry any) string {
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@mark3labs/kit",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mark3labs/kit",
"version": "0.0.0",
"cpu": [
"x64",
"arm64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"kit": "bin/kit"
},
"engines": {
"node": ">=16"
}
}
}
}
+2
View File
@@ -202,6 +202,7 @@ func Init(api ext.API) {
footer := harness.Context().GetFooter()
if footer == nil {
t.Fatal("expected footer to be set")
return
}
if footer.Content.Text != "Status: OK" {
t.Errorf("expected footer text 'Status: OK', got %q", footer.Content.Text)
@@ -258,6 +259,7 @@ func Init(api ext.API) {
if result == nil {
t.Fatal("expected non-nil result")
return
}
if !result.Block {
+10
View File
@@ -39,6 +39,9 @@ const (
EventCompaction EventType = "compaction"
// EventReasoningDelta fires for each streaming reasoning/thinking chunk.
EventReasoningDelta EventType = "reasoning_delta"
// EventReasoningComplete fires when reasoning/thinking is finished,
// after the last reasoning token has been processed.
EventReasoningComplete EventType = "reasoning_complete"
// EventToolOutput fires when a tool produces streaming output chunks.
EventToolOutput EventType = "tool_output"
EventStepUsage EventType = "step_usage"
@@ -149,6 +152,13 @@ type ReasoningDeltaEvent struct {
// EventType implements Event.
func (e ReasoningDeltaEvent) EventType() EventType { return EventReasoningDelta }
// ReasoningCompleteEvent fires when reasoning/thinking is finished, after the
// last reasoning token has been processed.
type ReasoningCompleteEvent struct{}
// EventType implements Event.
func (e ReasoningCompleteEvent) EventType() EventType { return EventReasoningComplete }
// ToolOutputEvent fires when a tool produces streaming output chunks (e.g., bash output).
type ToolOutputEvent struct {
ToolCallID string
+3
View File
@@ -177,6 +177,7 @@ func TestEventTypes(t *testing.T) {
{ResponseEvent{}, EventResponse},
{CompactionEvent{}, EventCompaction},
{ReasoningDeltaEvent{}, EventReasoningDelta},
{ReasoningCompleteEvent{}, EventReasoningComplete},
{ToolOutputEvent{}, EventToolOutput},
{StepUsageEvent{}, EventStepUsage},
{SteerConsumedEvent{}, EventSteerConsumed},
@@ -224,6 +225,7 @@ func TestEventOrdering(t *testing.T) {
EventMessageStart,
EventMessageUpdate,
EventReasoningDelta,
EventReasoningComplete,
EventToolOutput,
EventToolCall,
EventToolExecutionStart,
@@ -242,6 +244,7 @@ func TestEventOrdering(t *testing.T) {
bus.emit(MessageStartEvent{})
bus.emit(MessageUpdateEvent{Chunk: "hello"})
bus.emit(ReasoningDeltaEvent{Delta: "thinking..."})
bus.emit(ReasoningCompleteEvent{})
bus.emit(ToolOutputEvent{ToolName: "bash", Chunk: "output"})
bus.emit(ToolCallEvent{ToolName: "bash"})
bus.emit(ToolExecutionStartEvent{ToolName: "bash"})
+243 -101
View File
@@ -48,6 +48,7 @@ type Kit struct {
skills []*skills.Skill
extRunner *extensions.Runner
bufferedLogger *tools.BufferedDebugLogger
authHandler MCPAuthHandler // OAuth handler for remote MCP servers (may need Close)
// Hook registries — interception layer (see hooks.go).
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
@@ -80,8 +81,8 @@ type Kit struct {
// the running agent turn via the LLM library's PrepareStep. Created fresh for
// each generate() call and set to nil when idle. Protected by steerMu.
steerMu sync.Mutex
steerCh chan string
leftoverSteer []string // unconsumed steer messages from the last turn
steerCh chan agent.SteerMessage
leftoverSteer []agent.SteerMessage // unconsumed steer messages from the last turn
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -268,8 +269,8 @@ func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
}
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
// commands, renderers, and shortcuts update immediately. Extension-defined
// tools are NOT updated (they are baked into the agent at creation time).
// commands, renderers, shortcuts, and extension-defined tools all update
// immediately.
func (m *Kit) ReloadExtensions() error {
if m.extRunner == nil {
return fmt.Errorf("no extensions loaded")
@@ -290,6 +291,12 @@ func (m *Kit) ReloadExtensions() error {
// Swap extensions on the runner (clears dynamic state).
m.extRunner.Reload(loaded)
// Update extension tools on the agent so the LLM sees changes.
if m.agent != nil {
extTools := extensions.ExtensionToolsAsFantasy(m.extRunner.RegisteredTools(), m.extRunner)
m.agent.SetExtraTools(extTools)
}
// Re-set context and emit SessionStart.
ctx := m.extRunner.GetContext()
m.extRunner.SetContext(ctx)
@@ -433,6 +440,18 @@ type Options struct {
// Debug enables debug logging for the SDK.
Debug bool
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports (streamable HTTP, SSE) are configured with
// OAuth support. If the server returns a 401, the handler is invoked to
// let the user authorize via browser.
//
// If nil, a [DefaultMCPAuthHandler] is created automatically — opening the
// system browser and listening on a local callback server.
//
// Set to a custom implementation to control the authorization UX (e.g.
// display a URL in a custom UI, redirect to a web app, etc.).
MCPAuthHandler MCPAuthHandler
// CLI is optional CLI-specific configuration. SDK users leave this nil.
CLI *CLIOptions
}
@@ -499,85 +518,125 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
opts = &Options{}
}
viperInitMu.Lock()
defer viperInitMu.Unlock()
// All viper writes (SetSDKDefaults, InitConfig, Set calls, system-prompt
// composition) happen under viperInitMu. We also call BuildProviderConfig
// here — it's fast (just reads) — so we can capture the full config
// snapshot before releasing the lock. The expensive work (MCP loading,
// provider creation, session init) then runs outside the lock, allowing
// parallel subagent spawns to proceed concurrently.
var (
providerConfig *models.ProviderConfig
modelString string
cwd string
contextFiles []*ContextFile
loadedSkills []*Skill
mcpConfig *config.Config
debug bool
noExtensions bool
maxSteps int
streaming bool
)
// Set CLI-equivalent defaults for viper. When used as an SDK (without
// cobra), these defaults are not registered via flag bindings.
setSDKDefaults()
if err := func() error {
viperInitMu.Lock()
defer viperInitMu.Unlock()
// Initialize config (loads config files and env vars).
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
// Check if model is already set, which indicates config was loaded.
if viper.GetString("model") == "" {
if err := InitConfig(opts.ConfigFile, false); err != nil {
return nil, fmt.Errorf("failed to initialize config: %w", err)
}
}
// Set CLI-equivalent defaults for viper. When used as an SDK (without
// cobra), these defaults are not registered via flag bindings.
setSDKDefaults()
// Handle CLI debug mode.
if opts.Debug {
viper.Set("debug", true)
}
// Override viper settings with options.
if opts.Model != "" {
viper.Set("model", opts.Model)
}
if opts.SystemPrompt != "" {
viper.Set("system-prompt", opts.SystemPrompt)
}
if opts.MaxSteps > 0 {
viper.Set("max-steps", opts.MaxSteps)
}
viper.Set("stream", opts.Streaming)
// Resolve working directory for context/skill discovery.
cwd := opts.SessionDir
if cwd == "" {
cwd, _ = os.Getwd()
}
// Load context files (AGENTS.md) from the project root.
contextFiles := loadContextFiles(cwd)
// Load skills — either from explicit paths or via auto-discovery.
loadedSkills, err := loadSkills(opts)
if err != nil {
return nil, fmt.Errorf("failed to load skills: %w", err)
}
// Always compose the system prompt with runtime context: base prompt +
// AGENTS.md context + skills metadata + date/cwd.
{
basePrompt := viper.GetString("system-prompt")
pb := skills.NewPromptBuilder(basePrompt)
// Inject AGENTS.md content as project context.
for _, cf := range contextFiles {
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
// Initialize config (loads config files and env vars).
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
// Check if model is already set, which indicates config was loaded.
if viper.GetString("model") == "" {
if err := InitConfig(opts.ConfigFile, false); err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
// Inject skills metadata (name + description + location).
if len(loadedSkills) > 0 {
pb.WithSkills(loadedSkills)
// Handle CLI debug mode.
if opts.Debug {
viper.Set("debug", true)
}
// Append current date/time and working directory.
pb.WithSection("", fmt.Sprintf(
"Current date and time: %s\nCurrent working directory: %s",
time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd,
))
// Override viper settings with options.
if opts.Model != "" {
viper.Set("model", opts.Model)
}
if opts.SystemPrompt != "" {
viper.Set("system-prompt", opts.SystemPrompt)
}
if opts.MaxSteps > 0 {
viper.Set("max-steps", opts.MaxSteps)
}
viper.Set("stream", opts.Streaming)
viper.Set("system-prompt", pb.Build())
// Resolve working directory for context/skill discovery.
cwd = opts.SessionDir
if cwd == "" {
cwd, _ = os.Getwd()
}
// Load context files (AGENTS.md) from the project root.
contextFiles = loadContextFiles(cwd)
// Load skills — either from explicit paths or via auto-discovery.
var err error
loadedSkills, err = loadSkills(opts)
if err != nil {
return fmt.Errorf("failed to load skills: %w", err)
}
// Always compose the system prompt with runtime context: base prompt +
// AGENTS.md context + skills metadata + date/cwd.
{
basePrompt := viper.GetString("system-prompt")
pb := skills.NewPromptBuilder(basePrompt)
// Inject AGENTS.md content as project context.
for _, cf := range contextFiles {
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
}
// Inject skills metadata (name + description + location).
if len(loadedSkills) > 0 {
pb.WithSkills(loadedSkills)
}
// Append current date/time and working directory.
pb.WithSection("", fmt.Sprintf(
"Current date and time: %s\nCurrent working directory: %s",
time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd,
))
viper.Set("system-prompt", pb.Build())
}
// Snapshot all viper-derived values now, while the lock is held.
// BuildProviderConfig is fast (pure reads), so we do it here.
var pcErr error
providerConfig, _, pcErr = kitsetup.BuildProviderConfig()
if pcErr != nil {
return fmt.Errorf("failed to build provider config: %w", pcErr)
}
modelString = viper.GetString("model")
debug = viper.GetBool("debug")
noExtensions = viper.GetBool("no-extensions")
maxSteps = viper.GetInt("max-steps")
streaming = viper.GetBool("stream")
return nil
}(); err != nil {
return nil, err
}
// ---- viperInitMu released — heavy I/O below runs concurrently ----
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
var mcpConfig *config.Config
if opts.CLI != nil {
if opts.CLI != nil && opts.CLI.MCPConfig != nil {
mcpConfig = opts.CLI.MCPConfig
}
if mcpConfig == nil {
var err error
mcpConfig, err = config.LoadAndValidateConfig()
if err != nil {
return nil, fmt.Errorf("failed to load MCP config: %w", err)
@@ -595,13 +654,37 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
// Build agent setup options, pulling CLI-specific fields when available.
// Pass the pre-built ProviderConfig and scalar viper snapshots so
// SetupAgent doesn't need to re-read viper (which would require the lock).
setupOpts := kitsetup.AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
ProviderConfig: providerConfig,
Debug: debug,
NoExtensions: noExtensions,
MaxSteps: maxSteps,
StreamingEnabled: streaming,
}
// Set up OAuth handler for remote MCP servers.
// The SDK MCPAuthHandler interface is structurally identical to
// tools.MCPAuthHandler, so any implementation satisfies both.
if opts.MCPAuthHandler != nil {
setupOpts.AuthHandler = opts.MCPAuthHandler
} else {
// Create a default handler that opens the system browser.
defaultHandler, authErr := NewDefaultMCPAuthHandler()
if authErr != nil {
// Non-fatal: OAuth just won't be available for remote servers.
charmlog.Warn("Failed to create OAuth handler; remote MCP servers requiring auth will fail", "error", authErr)
} else {
setupOpts.AuthHandler = defaultHandler
}
}
if opts.CLI != nil {
setupOpts.ShowSpinner = opts.CLI.ShowSpinner
setupOpts.SpinnerFunc = opts.CLI.SpinnerFunc
@@ -624,7 +707,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
k := &Kit{
agent: agentResult.Agent,
treeSession: treeSession,
modelString: viper.GetString("model"),
modelString: modelString,
events: newEventBus(),
autoCompact: opts.AutoCompact,
compactionOpts: opts.CompactionOptions,
@@ -632,6 +715,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
skills: loadedSkills,
extRunner: agentResult.ExtRunner,
bufferedLogger: agentResult.BufferedLogger,
authHandler: setupOpts.AuthHandler,
beforeToolCall: beforeToolCall,
afterToolResult: afterToolResult,
beforeTurn: beforeTurn,
@@ -904,6 +988,16 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
if timeout == 0 {
timeout = 5 * time.Minute
}
// Pre-flight check: if the incoming context is already dead, don't
// waste time attempting init. This catches the case where the parent
// generation loop's context was cancelled (e.g. user ESC, step cancel)
// between when the LLM requested the subagent tool and when this code
// runs. We replace it with a fresh context carrying only the timeout,
// since the subagent should be independently bounded.
if ctx.Err() != nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -920,6 +1014,17 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
}
}
// Early validation: check model format and provider before doing any
// expensive work (MCP init, system prompt composition, etc.). This
// gives the calling agent immediate feedback it can act on — e.g.
// correcting a typo — instead of waiting for a full Kit.New() cycle
// that silently falls back to the parent model.
if model != m.modelString {
if err := models.GetGlobalRegistry().ValidateModelString(model); err != nil {
return nil, fmt.Errorf("invalid subagent model %q: %w", model, err)
}
}
// Default system prompt.
systemPrompt := cfg.SystemPrompt
if systemPrompt == "" {
@@ -932,9 +1037,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
tools = SubagentTools()
}
// Create child Kit instance. If the requested model fails (bad name,
// unsupported provider, etc.), fall back to the parent's model so the
// agent gets a useful error message instead of a hard failure.
// Create child Kit instance.
childOpts := &Options{
Model: model,
SystemPrompt: systemPrompt,
@@ -943,20 +1046,8 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
Quiet: true,
}
child, err := New(ctx, childOpts)
if err != nil && model != m.modelString {
// Model-specific failure — retry with parent's model.
childOpts.Model = m.modelString
child, err = New(ctx, childOpts)
if err != nil {
return nil, fmt.Errorf("failed to create subagent: %w", err)
}
// Prepend a note so the agent knows which model is actually running.
cfg.Prompt = fmt.Sprintf(
"[Note: requested model %q was not available, using %s instead.]\n\n%s",
model, m.modelString, cfg.Prompt,
)
} else if err != nil {
return nil, fmt.Errorf("failed to create subagent: %w", err)
if err != nil {
return &SubagentResult{Elapsed: time.Since(start)}, fmt.Errorf("failed to create subagent: %w", err)
}
defer func() { _ = child.Close() }()
@@ -970,7 +1061,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
elapsed := time.Since(start)
if err != nil {
return nil, err
return &SubagentResult{Elapsed: elapsed}, err
}
subResult := &SubagentResult{
@@ -996,14 +1087,14 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
// Create a per-turn steer channel and attach it to the context so the
// agent's PrepareStep can inject steering messages between steps.
steerCh := make(chan string, 16)
steerCh := make(chan agent.SteerMessage, 16)
m.steerMu.Lock()
m.steerCh = steerCh
m.steerMu.Unlock()
defer func() {
// Drain any unconsumed steer messages before nilling the channel.
// These are stored in leftoverSteer so DrainSteer() can return them.
var leftover []string
var leftover []agent.SteerMessage
for {
select {
case msg := <-steerCh:
@@ -1093,12 +1184,52 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
func(content string) {
m.events.emit(ToolCallContentEvent{Content: content})
},
func(chunk string) {
m.events.emit(MessageUpdateEvent{Chunk: chunk})
},
// <think> tag filtering: models like Qwen/DeepSeek wrap reasoning inside
// <think>...</think> tags in the regular text stream. We intercept those
// spans here and re-route them as ReasoningDeltaEvent/ReasoningCompleteEvent
// so callers always receive clean, tag-free text and structured reasoning.
func() func(chunk string) {
const (
thinkOpen = "<think>"
thinkClose = "</think>"
)
var inThinkTag bool
return func(chunk string) {
remaining := chunk
for remaining != "" {
if inThinkTag {
i := strings.Index(remaining, thinkClose)
if i == -1 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining})
return
}
if i > 0 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining[:i]})
}
inThinkTag = false
m.events.emit(ReasoningCompleteEvent{})
remaining = remaining[i+len(thinkClose):]
} else {
i := strings.Index(remaining, thinkOpen)
if i == -1 {
m.events.emit(MessageUpdateEvent{Chunk: remaining})
return
}
if i > 0 {
m.events.emit(MessageUpdateEvent{Chunk: remaining[:i]})
}
inThinkTag = true
remaining = remaining[i+len(thinkOpen):]
}
}
}
}(),
func(delta string) {
m.events.emit(ReasoningDeltaEvent{Delta: delta})
},
func() {
m.events.emit(ReasoningCompleteEvent{})
},
func(toolCallID, toolName, chunk string, isStderr bool) {
// Emit tool output chunk event for streaming bash output
m.events.emit(ToolOutputEvent{
@@ -1344,6 +1475,13 @@ func (m *Kit) FollowUp(ctx context.Context, text string) (string, error) {
// This is the preferred way to redirect an agent mid-turn without cancelling
// in-progress tool execution.
func (m *Kit) InjectSteer(message string) {
m.InjectSteerWithFiles(message, nil)
}
// InjectSteerWithFiles sends a steering message with optional file attachments
// (e.g. pasted images) into the currently active agent turn. Behaves like
// InjectSteer but includes file parts in the injected user message.
func (m *Kit) InjectSteerWithFiles(message string, files []LLMFilePart) {
m.steerMu.Lock()
ch := m.steerCh
m.steerMu.Unlock()
@@ -1351,7 +1489,7 @@ func (m *Kit) InjectSteer(message string) {
return
}
select {
case ch <- message:
case ch <- agent.SteerMessage{Text: message, Files: files}:
default:
// Channel full — extremely unlikely with buffer of 16, but don't block.
}
@@ -1369,7 +1507,7 @@ func (m *Kit) IsGenerating() bool {
// a turn completes so the app layer can process any steer messages that
// arrived after the last PrepareStep fired (e.g. during a text-only response
// with no tool calls, or after the agent finished its last step).
func (m *Kit) DrainSteer() []string {
func (m *Kit) DrainSteer() []agent.SteerMessage {
m.steerMu.Lock()
defer m.steerMu.Unlock()
@@ -1382,7 +1520,7 @@ func (m *Kit) DrainSteer() []string {
// If a turn is still active, drain from the live channel.
if m.steerCh != nil {
var msgs []string
var msgs []agent.SteerMessage
for {
select {
case msg := <-m.steerCh:
@@ -1538,5 +1676,9 @@ func (m *Kit) Close() error {
if m.treeSession != nil {
_ = m.treeSession.Close()
}
// Release the OAuth callback port if we own the handler.
if closer, ok := m.authHandler.(interface{ Close() error }); ok {
_ = closer.Close()
}
return m.agent.Close()
}
+265
View File
@@ -0,0 +1,265 @@
package kit
import (
"context"
"fmt"
"net"
"net/http"
"os/exec"
"runtime"
"sync"
"time"
)
// MCPAuthHandler handles OAuth authorization for MCP servers.
// Implementations control the user experience — opening a browser, showing a
// prompt, displaying a URL, etc.
//
// The default implementation ([DefaultMCPAuthHandler]) opens the system browser
// and starts a local HTTP callback server to receive the authorization code.
type MCPAuthHandler interface {
// RedirectURI returns the OAuth redirect URI that the callback server
// will listen on. This is called during MCP transport setup — before any
// OAuth errors occur — so the redirect URI can be registered with the
// authorization server.
RedirectURI() string
// HandleAuth is called when an MCP server requires OAuth authorization.
// It receives the server name and an authorization URL that the user must
// visit. The handler must:
// 1. Direct the user to authURL (e.g. open browser, display URL)
// 2. Listen for the OAuth callback on the redirect URI
// 3. Return the full callback URL (with code and state query params)
//
// Return an error to abort the connection to this MCP server.
// The context controls the overall timeout; implementations should
// respect ctx.Done().
HandleAuth(ctx context.Context, serverName string, authURL string) (callbackURL string, err error)
}
// DefaultMCPAuthHandler opens the system browser and starts a local HTTP
// callback server to receive the OAuth authorization code. It eagerly reserves
// a TCP port on construction so [RedirectURI] is stable for the lifetime of
// the handler.
//
// Create instances with [NewDefaultMCPAuthHandler] (random port) or
// [NewDefaultMCPAuthHandlerWithPort] (explicit port).
type DefaultMCPAuthHandler struct {
listener net.Listener
port int
mu sync.Mutex // guards listener lifecycle
}
// NewDefaultMCPAuthHandler creates a handler that listens on a random
// available port on localhost. The port is reserved immediately so
// [RedirectURI] returns a stable value. Call [DefaultMCPAuthHandler.Close]
// when the handler is no longer needed to release the port.
func NewDefaultMCPAuthHandler() (*DefaultMCPAuthHandler, error) {
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, fmt.Errorf("failed to listen for OAuth callback: %w", err)
}
port := listener.Addr().(*net.TCPAddr).Port
return &DefaultMCPAuthHandler{listener: listener, port: port}, nil
}
// NewDefaultMCPAuthHandlerWithPort creates a handler that listens on the
// specified port on localhost. The port is reserved immediately. Pass 0 to
// let the OS pick a free port (equivalent to [NewDefaultMCPAuthHandler]).
// Call [DefaultMCPAuthHandler.Close] when the handler is no longer needed.
func NewDefaultMCPAuthHandlerWithPort(port int) (*DefaultMCPAuthHandler, error) {
addr := fmt.Sprintf("localhost:%d", port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s for OAuth callback: %w", addr, err)
}
actualPort := listener.Addr().(*net.TCPAddr).Port
return &DefaultMCPAuthHandler{listener: listener, port: actualPort}, nil
}
// RedirectURI returns the OAuth redirect URI pointing to the local callback
// server. This value is stable for the lifetime of the handler.
func (h *DefaultMCPAuthHandler) RedirectURI() string {
return fmt.Sprintf("http://localhost:%d/oauth/callback", h.port)
}
// Port returns the TCP port the callback server is bound to.
func (h *DefaultMCPAuthHandler) Port() int {
return h.port
}
// HandleAuth opens the system browser to authURL and waits for the OAuth
// callback on the local server. It returns the full callback URL including
// query parameters (code, state, etc.).
//
// If the context has no deadline, a default 2-minute timeout is applied.
// The callback server is started for each HandleAuth call and shut down
// before returning.
func (h *DefaultMCPAuthHandler) HandleAuth(ctx context.Context, serverName string, authURL string) (string, error) {
h.mu.Lock()
listener := h.listener
h.mu.Unlock()
if listener == nil {
return "", fmt.Errorf("OAuth callback handler is closed")
}
// Apply default timeout if the context has no deadline.
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
}
// Channel receives the full callback URL from the HTTP handler.
callbackCh := make(chan string, 1)
mux := http.NewServeMux()
mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
// Reconstruct the full callback URL as the caller expects it.
fullURL := fmt.Sprintf("http://localhost:%d%s", h.port, r.RequestURI)
// Send the callback URL to the waiting goroutine (non-blocking).
select {
case callbackCh <- fullURL:
default:
}
// Respond with a friendly HTML page so the user knows they can
// close the browser tab.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, oauthSuccessHTML)
})
server := &http.Server{
Handler: mux,
}
// Start serving on the pre-reserved listener. We need to create a new
// listener on the same port because http.Server.Serve takes ownership
// and closes the listener when done. The original listener is kept open
// to reserve the port; we create a second listener via SO_REUSEADDR
// semantics (Go's default on most platforms) or, more reliably, we
// temporarily release and re-acquire.
//
// Strategy: use the held listener directly for Serve. After Serve
// returns (due to Shutdown), re-acquire the listener to keep the port
// reserved for future HandleAuth calls.
h.mu.Lock()
serveListener := h.listener
h.listener = nil // Serve will close it
h.mu.Unlock()
if serveListener == nil {
return "", fmt.Errorf("OAuth callback handler is closed")
}
// Start the HTTP server in a background goroutine.
serverErrCh := make(chan error, 1)
go func() {
err := server.Serve(serveListener)
if err != nil && err != http.ErrServerClosed {
serverErrCh <- err
}
close(serverErrCh)
}()
// Re-acquire the listener after Serve completes (deferred).
defer func() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
// Re-reserve the port for future HandleAuth calls.
h.mu.Lock()
defer h.mu.Unlock()
if h.listener == nil {
newListener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", h.port))
if err == nil {
h.listener = newListener
}
// If re-listen fails, the handler degrades gracefully — the
// next HandleAuth call will return an error.
}
}()
// Open the system browser.
if err := openBrowser(authURL); err != nil {
// Browser open is best-effort; the user can still navigate manually.
_ = err
}
// Wait for the callback, a server error, or context cancellation.
select {
case url := <-callbackCh:
return url, nil
case err := <-serverErrCh:
return "", fmt.Errorf("OAuth callback server error for %q: %w", serverName, err)
case <-ctx.Done():
return "", fmt.Errorf("OAuth authorization timed out for %q: %w", serverName, ctx.Err())
}
}
// Close releases the reserved port and shuts down the handler. After Close,
// HandleAuth will return an error. Close is safe to call multiple times.
func (h *DefaultMCPAuthHandler) Close() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.listener != nil {
err := h.listener.Close()
h.listener = nil
return err
}
return nil
}
// openBrowser opens the default system browser to the given URL. This is a
// best-effort operation — errors are returned but callers typically ignore
// them since the user can navigate manually.
func openBrowser(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
}
// oauthSuccessHTML is the HTML page returned to the browser after a
// successful OAuth callback.
const oauthSuccessHTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authorization Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f8f9fa;
color: #333;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { color: #22863a; }
p { color: #586069; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>&#10003; Authorization Successful</h1>
<p>You can close this tab and return to the terminal.</p>
</div>
</body>
</html>`
+68
View File
@@ -0,0 +1,68 @@
package kit
import (
"context"
"fmt"
"io"
"os"
)
// CLIMCPAuthHandler wraps a [DefaultMCPAuthHandler] and prints status messages
// to a writer (typically stderr) so the user knows what's happening during
// OAuth authorization. This is the handler used by the CLI/TUI binary.
//
// For TUI integration, set NotifyFunc to route messages through the TUI's
// event system instead of (or in addition to) the writer.
type CLIMCPAuthHandler struct {
inner *DefaultMCPAuthHandler
w io.Writer
// NotifyFunc, when set, is called with status messages instead of writing
// to the writer. This allows the TUI to display system messages in the
// chat stream. If nil, messages are written to w.
NotifyFunc func(serverName, message string)
}
// NewCLIMCPAuthHandler creates a CLI auth handler that prints status messages
// to stderr and delegates the actual OAuth flow to a [DefaultMCPAuthHandler].
func NewCLIMCPAuthHandler() (*CLIMCPAuthHandler, error) {
inner, err := NewDefaultMCPAuthHandler()
if err != nil {
return nil, err
}
return &CLIMCPAuthHandler{inner: inner, w: os.Stderr}, nil
}
// RedirectURI returns the OAuth redirect URI from the inner handler.
func (h *CLIMCPAuthHandler) RedirectURI() string {
return h.inner.RedirectURI()
}
// HandleAuth prints status messages and delegates to the inner handler.
func (h *CLIMCPAuthHandler) HandleAuth(ctx context.Context, serverName string, authURL string) (string, error) {
h.notify(serverName, fmt.Sprintf("🔐 MCP server %q requires authentication. Opening browser...", serverName))
h.notify(serverName, fmt.Sprintf(" If the browser doesn't open, visit:\n %s", authURL))
callbackURL, err := h.inner.HandleAuth(ctx, serverName, authURL)
if err != nil {
h.notify(serverName, fmt.Sprintf("✗ Authentication failed for %q: %v", serverName, err))
return "", err
}
h.notify(serverName, fmt.Sprintf("✓ Authenticated with %q", serverName))
return callbackURL, nil
}
// Close releases the inner handler's resources.
func (h *CLIMCPAuthHandler) Close() error {
return h.inner.Close()
}
// notify sends a message through NotifyFunc if set, otherwise writes to w.
func (h *CLIMCPAuthHandler) notify(serverName, message string) {
if h.NotifyFunc != nil {
h.NotifyFunc(serverName, message)
return
}
_, _ = fmt.Fprintln(h.w, message)
}
+5 -1
View File
@@ -91,7 +91,11 @@ api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
// e.StopReason string — "error" (on failure), "completed" (when LLM returns
// empty stop reason), or the raw LLM provider value passed through
// (e.g. "stop", "end_turn", "max_tokens", "tool_use").
// To detect errors, check e.StopReason == "error".
// Do NOT compare against "completed" for success — instead check != "error".
})
```
+6757
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -73,7 +73,7 @@ These commands are available inside the Kit TUI during an interactive session:
| `/usage` | Show token usage |
| `/reset-usage` | Reset usage statistics |
| `/tree` | Navigate session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message |
| `/new` | Start a new session (creates new session file) |
| `/name [name]` | Set or show session display name |
| `/resume` | Open session picker to switch sessions (alias: `/r`) |
+11 -1
View File
@@ -104,6 +104,8 @@ Define custom models in your `.kit.yml` for use with the `custom` provider. This
customModels:
my-model:
name: "My Custom Model"
baseUrl: "http://localhost:8080/v1"
apiKey: "my-secret-key"
reasoning: true
temperature: true
cost:
@@ -119,6 +121,8 @@ customModels:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Display name for the model |
| `baseUrl` | string | No | Per-model base URL override; when set, `--provider-url` is not required |
| `apiKey` | string | No | Per-model API key override |
| `reasoning` | bool | No | Whether the model supports reasoning/thinking |
| `temperature` | bool | No | Whether the model supports temperature adjustment |
| `cost.input` | float | No | Cost per 1K input tokens |
@@ -126,7 +130,13 @@ customModels:
| `limit.context` | int | Yes | Maximum context window in tokens |
| `limit.output` | int | No | Maximum output tokens |
Use with a custom provider URL:
Use with a per-model `baseUrl` (no `--provider-url` needed):
```bash
kit --model custom/my-model "Hello"
```
Or override the base URL at runtime:
```bash
kit --provider-url "http://localhost:8080/v1" --model custom/my-model "Hello"
+1 -1
View File
@@ -80,7 +80,7 @@ These slash commands are available during an interactive session:
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message (creates new session file) |
| `/new` | Start a new session (creates new session file) |
## Ephemeral mode