Compare commits

...

7 Commits

Author SHA1 Message Date
Ed Zynda d24540693c refactor: simplify max line chars calculation with builtin max() 2026-03-22 14:35:26 +03:00
Ed Zynda f7c8e7757b feat: persist model selection and thinking level across sessions
Model and thinking level choices now survive restarts, matching the
existing theme persistence pattern. Selections are saved to
~/.config/kit/preferences.yml and restored on next launch.

Precedence: CLI flag > config file > saved preference > default

Changes:
- Extended preferences struct with model and thinking_level fields
- Refactored preferences.go to shared load/save helpers (DRY)
- Added SaveModelPreference/LoadModelPreference
- Added SaveThinkingLevelPreference/LoadThinkingLevelPreference
- Persist on /model, model selector, /thinking, and Shift+Tab cycle
- Restore at startup in runNormalMode when no explicit flag/config
- Added modelFlagChanged/thinkingFlagChanged to detect explicit flags
- Comprehensive tests for all preference operations
2026-03-22 13:52:06 +03:00
Ed Zynda 0d5374b17b fix: truncate long lines in tool messages instead of wrapping
Use ANSI-aware truncation (charmbracelet/x/ansi) to prevent ugly line
wrapping in all tool renderers (Bash, Read, Write, Ls, Edit, Subagent).

- Replace byte-length padRight/truncateLine with xansi.StringWidth and
  xansi.Truncate which correctly handle ANSI escape codes and wide chars
- Bash: truncate lines to fit panel width instead of allowing 3x wrapping
- Read/Write: truncate syntax-highlighted lines before lipgloss renders
- Ls: truncate entries before styling
- Compact renderers: use ANSI-aware truncation consistently
2026-03-22 13:41:57 +03:00
Ed Zynda 25f17a104d fix: truncate long individual lines to prevent TUI blow-up
A single very long line (e.g. minified JSON, base64 blob) could wrap
into hundreds of visual rows in the TUI even when within the line-count
and byte-count limits.

Core layer (truncate.go):
- Add defaultMaxLineLen (2000 chars) per-line cap
- Apply truncateLongLines() in both TruncateTail and truncateHead
  before line/byte truncation
- Append '[N chars truncated]' marker to capped lines

UI layer:
- Cap lines in renderBashBody() to width*3 chars before rendering
- Cap lines in shell display handler (model.go) similarly

Add comprehensive tests in truncate_test.go.
2026-03-22 13:31:25 +03:00
Ed Zynda 20125f939b feat: add /share command and session viewer
- Add /share slash command that uploads session JSONL to GitHub Gist
  via the gh CLI and prints a shareable viewer URL
- Add session viewer SPA at www/public/session/index.html served at
  go-kit.dev/session/#GIST_ID
- Viewer supports all message types: text, reasoning/thinking blocks,
  tool calls (bash, read, write, edit, grep, find, ls, spawn_subagent),
  images, model changes, branch summaries, and labels
- Tool-specific rendering with syntax highlighting, diffs, collapsible
  output, and status badges
- Also supports ?url= query param for loading from any JSONL URL
- Dark theme matching Kit brand colors
2026-03-22 13:23:44 +03:00
Ed Zynda d3b67ffd14 feat(ui): render session history on /resume and /import
When a user resumes or imports a session, all conversation messages are
now rendered into the TUI scrollback buffer, giving visual context of
the prior conversation. This includes:

- User messages with original text
- Assistant responses with model name from the session
- Tool calls with name, args, and full output/error status

The implementation does a two-pass walk over the session branch:
1. Builds a toolCallID → {name, args} map from assistant messages
2. Renders each message entry using the existing print helpers

Wired into both the /resume session picker (SessionSelectedMsg handler)
and the /import command handler.
2026-03-22 00:57:57 +03:00
Ed Zynda 915dc066dd docs: update documentation for recent features
- Document theme persistence in themes.md and README.md
- Document session commands (/resume, /export, /import, /name) in commands.md and sessions.md
- Document prompt history (up/down arrows) in commands.md
- Document SubscribeSubagent API in sdk/callbacks.md and advanced/subagents.md
2026-03-21 21:15:27 +03:00
15 changed files with 2805 additions and 41 deletions
+2
View File
@@ -232,6 +232,8 @@ Kit ships with 22 built-in color themes that control all UI elements. Switch at
/theme tokyonight
```
Theme selections are automatically saved and restored on next launch (stored in `~/.config/kit/preferences.yml`).
### Custom themes
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
+32
View File
@@ -65,6 +65,11 @@ var (
// TLS configuration
tlsSkipVerify bool
// Preference restoration flags — set in RunE after cobra parses, used
// in runNormalMode to decide whether to apply saved preferences.
modelFlagChanged bool
thinkingFlagChanged bool
)
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
@@ -113,6 +118,17 @@ var rootCmd = &cobra.Command{
if len(args) > 0 {
processPositionalArgs(args)
}
// Record whether --model / --thinking-level were explicitly set by the
// user so that runNormalMode can fall back to saved preferences when
// they weren't. Must be captured here (after cobra parses) and before
// runKit because rootCmd can't be referenced inside runNormalMode
// without creating an initialization cycle.
if f := cmd.PersistentFlags().Lookup("model"); f != nil {
modelFlagChanged = f.Changed
}
if f := cmd.PersistentFlags().Lookup("thinking-level"); f != nil {
thinkingFlagChanged = f.Changed
}
return runKit(context.Background())
},
}
@@ -646,6 +662,22 @@ func runNormalMode(ctx context.Context) error {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Restore persisted model preference when no explicit --model flag or
// config file model is set. Precedence: CLI flag > config file > saved
// preference > built-in default. This mirrors how themes are persisted.
if !modelFlagChanged && !viper.InConfig("model") {
if pref := ui.LoadModelPreference(); pref != "" {
viper.Set("model", pref)
}
}
// Restore persisted thinking level preference (same precedence chain).
if !thinkingFlagChanged && !viper.InConfig("thinking-level") {
if pref := ui.LoadThinkingLevelPreference(); pref != "" {
viper.Set("thinking-level", pref)
}
}
// Load MCP configuration.
mcpConfig, err := config.LoadAndValidateConfig()
if err != nil {
+28 -10
View File
@@ -6,14 +6,17 @@ import (
)
const (
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
grepMaxLineLen = 500
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
defaultMaxLineLen = 2000 // max characters per line before truncation
grepMaxLineLen = 500
// DefaultMaxLines is the exported default line limit for truncation.
DefaultMaxLines = defaultMaxLines
// DefaultMaxBytes is the exported default byte limit for truncation.
DefaultMaxBytes = defaultMaxBytes
// DefaultMaxLineLen is the exported default per-line character limit.
DefaultMaxLineLen = defaultMaxLineLen
)
// TruncationResult describes how output was truncated.
@@ -26,6 +29,8 @@ type TruncationResult struct {
}
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated to prevent
// extremely long single lines from blowing up the TUI when wrapped.
// Used for bash output where the tail is most relevant.
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -38,11 +43,11 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first to prevent single lines from
// wrapping into hundreds of visual lines in the TUI.
lines = truncateLongLines(lines, defaultMaxLineLen)
// Truncate by lines first (keep tail)
// Truncate by lines (keep tail)
truncBy := ""
if total > maxLines {
lines = lines[total-maxLines:]
@@ -78,6 +83,7 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
}
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated.
// Used for read, grep, find, ls output where the head is most relevant.
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -90,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first.
lines = truncateLongLines(lines, defaultMaxLineLen)
truncBy := ""
if total > maxLines {
@@ -125,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
}
}
// truncateLongLines caps each line to maxLen characters, appending a
// "[...N chars truncated]" marker to any line that exceeds the limit.
// This prevents a single very long line (e.g. minified JSON/JS) from
// wrapping into hundreds of visual rows and blowing up the TUI.
func truncateLongLines(lines []string, maxLen int) []string {
for i, line := range lines {
if len(line) > maxLen {
lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen)
}
}
return lines
}
// truncateLine truncates a single line to maxChars, appending "..." if cut.
func truncateLine(line string, maxChars int) string {
if maxChars <= 0 {
+163
View File
@@ -0,0 +1,163 @@
package core
import (
"strings"
"testing"
)
func TestTruncateTail_LongLines(t *testing.T) {
// A single line of 5000 chars should be truncated to defaultMaxLineLen.
longLine := strings.Repeat("x", 5000)
tr := TruncateTail(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateTail_NormalLines(t *testing.T) {
// Lines within the limit should pass through unchanged.
content := "line1\nline2\nline3"
tr := TruncateTail(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateTail_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateHead_LongLines(t *testing.T) {
longLine := strings.Repeat("y", 5000)
tr := truncateHead(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 {
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateHead_NormalLines(t *testing.T) {
content := "line1\nline2\nline3"
tr := truncateHead(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateHead_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := truncateHead(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateLongLines(t *testing.T) {
lines := []string{
"short",
strings.Repeat("a", 3000),
"also short",
}
result := truncateLongLines(lines, 100)
if result[0] != "short" {
t.Error("short line should be unchanged")
}
if len(result[1]) > 200 { // 100 chars + marker
t.Errorf("long line not truncated: len=%d", len(result[1]))
}
if !strings.Contains(result[1], "chars truncated]") {
t.Error("should contain truncation marker")
}
if result[2] != "also short" {
t.Error("short line should be unchanged")
}
}
func TestTruncateTail_MixedLongAndManyLines(t *testing.T) {
// 50 lines, each 3000 chars — tests both per-line and total truncation.
lines := make([]string, 50)
for i := range lines {
lines[i] = strings.Repeat("z", 3000)
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
// Should keep 10 lines.
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
// Each line should be capped at ~defaultMaxLineLen.
resultLines := strings.Split(tr.Content, "\n")
for i, line := range resultLines {
if len(line) > defaultMaxLineLen+100 {
t.Errorf("line %d too long: %d chars", i, len(line))
}
}
}
func TestTruncateLine(t *testing.T) {
short := "hello"
if truncateLine(short, 10) != short {
t.Error("short line should be unchanged")
}
long := strings.Repeat("x", 100)
result := truncateLine(long, 10)
if len(result) != 13 { // 10 + "..."
t.Errorf("got len %d, want 13", len(result))
}
// Default max for 0 — input shorter than default, so unchanged
result2 := truncateLine(long, 0)
if result2 != long {
t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen)
}
// Longer input with default
veryLong := strings.Repeat("x", 1000)
result3 := truncateLine(veryLong, 0)
if len(result3) != grepMaxLineLen+3 {
t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3)
}
}
+5
View File
@@ -152,6 +152,11 @@ var SlashCommands = []SlashCommand{
Description: "Export session (JSONL by default, or /export path.jsonl)",
Category: "System",
},
{
Name: "/share",
Description: "Share session via GitHub Gist (requires gh CLI)",
Category: "System",
},
{
Name: "/import",
Description: "Import a session from a JSONL file (/import path.jsonl)",
+212
View File
@@ -15,6 +15,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/core"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
)
@@ -879,6 +880,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modelName = parts[1]
}
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(msg.ModelString) }()
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
@@ -903,6 +906,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err := m.switchSession(msg.Path); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch session: %v", err))
} else {
m.renderSessionHistory()
m.printSystemMessage("Session loaded. Continue where you left off.")
}
} else {
@@ -1501,6 +1505,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
case shareResultMsg:
if msg.err != nil {
m.printSystemMessage(fmt.Sprintf("Share failed: %v", msg.err))
} else {
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
}
return m, m.drainScrollback()
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
@@ -1763,6 +1775,9 @@ func (m *AppModel) cycleThinkingLevel() {
_ = m.setThinkingLevel(next)
}()
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(next) }()
}
// renderSeparator renders the separator line with an optional queue count badge.
@@ -1978,6 +1993,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return m.handleResumeCommand()
case "/export":
return m.handleExportCommand("")
case "/share":
return m.handleShareCommand()
case "/import":
return m.handleImportCommand("")
case "/session":
@@ -2510,6 +2527,9 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
go emit(newModel, prev, "user")
}
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(args) }()
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
}
@@ -2599,6 +2619,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
_ = m.setThinkingLevel(string(level))
}()
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(string(level)) }()
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
@@ -2797,6 +2819,93 @@ func (m *AppModel) handleExportCommand(args string) tea.Cmd {
return nil
}
// handleShareCommand uploads the current session as a GitHub Gist and prints
// a shareable viewer URL. Requires the GitHub CLI (gh) to be installed and
// authenticated.
func (m *AppModel) handleShareCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
srcPath := ts.GetFilePath()
if srcPath == "" {
m.printSystemMessage("Session is in-memory (not persisted). Nothing to share.")
return nil
}
// Check that gh CLI is available.
if _, err := exec.LookPath("gh"); err != nil {
m.printSystemMessage("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")
return nil
}
// Check that gh is authenticated.
authCheck := exec.Command("gh", "auth", "status")
if err := authCheck.Run(); err != nil {
m.printSystemMessage("GitHub CLI is not logged in. Run 'gh auth login' first.")
return nil
}
// Copy session to a temp file with a clean name.
data, err := os.ReadFile(srcPath)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
return nil
}
name := ts.GetSessionName()
if name == "" {
name = "session"
}
// Sanitize for filename.
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == ' ' {
return '_'
}
return r
}, name)
tmpFile, err := os.CreateTemp("", fmt.Sprintf("kit-%s-*.jsonl", name))
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to create temp file: %v", err))
return nil
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
m.printSystemMessage(fmt.Sprintf("Failed to write temp file: %v", err))
return nil
}
_ = tmpFile.Close()
m.printSystemMessage("Uploading session to GitHub Gist...")
// Run gh gist create in background to avoid blocking the UI.
return func() tea.Msg {
defer func() { _ = os.Remove(tmpPath) }()
cmd := exec.Command("gh", "gist", "create", tmpPath, "--desc", "Kit session shared via /share")
output, err := cmd.Output()
if err != nil {
return shareResultMsg{err: fmt.Errorf("failed to create gist: %w", err)}
}
// gh outputs the gist URL like: https://gist.github.com/username/abc123def456
gistURL := strings.TrimSpace(string(output))
// Extract gist ID (last path segment).
parts := strings.Split(gistURL, "/")
gistID := parts[len(parts)-1]
viewerURL := fmt.Sprintf("https://go-kit.dev/session/#%s", gistID)
return shareResultMsg{gistURL: gistURL, viewerURL: viewerURL}
}
}
// handleImportCommand imports a session from a JSONL file.
// Usage: /import path.jsonl
func (m *AppModel) handleImportCommand(args string) tea.Cmd {
@@ -2821,6 +2930,7 @@ func (m *AppModel) handleImportCommand(args string) tea.Cmd {
return nil
}
m.renderSessionHistory()
m.printSystemMessage(fmt.Sprintf("Session imported from: %s", args))
return nil
}
@@ -2837,6 +2947,91 @@ func (m *AppModel) handleResumeCommand() tea.Cmd {
return nil
}
// renderSessionHistory walks the current session branch and renders all
// messages (user, assistant, tool calls/results) into the scrollback buffer.
// This gives the user visual context of the conversation when resuming or
// importing a session. Call this after switchSession succeeds.
func (m *AppModel) renderSessionHistory() {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return
}
branch := ts.GetBranch("")
if len(branch) == 0 {
return
}
// First pass: build a map of tool call ID → {name, args} from assistant
// messages so we can pair them with tool results.
type toolCallInfo struct {
Name string
Args string
}
toolCallMap := make(map[string]toolCallInfo)
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
if me.Role != "assistant" {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
for _, tc := range msg.ToolCalls() {
toolCallMap[tc.ID] = toolCallInfo{Name: tc.Name, Args: tc.Input}
}
}
// Second pass: render each message in order.
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
switch msg.Role {
case message.RoleUser:
text := msg.Content()
if text != "" {
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
}
case message.RoleAssistant:
text := msg.Content()
if text != "" {
modelName := m.modelName
if msg.Model != "" {
modelName = msg.Model
}
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
}
// Tool calls from assistant messages are rendered when we
// encounter their corresponding tool results below.
case message.RoleTool:
for _, tr := range msg.ToolResults() {
toolName := tr.Name
toolArgs := ""
if info, ok := toolCallMap[tr.ToolCallID]; ok {
if toolName == "" {
toolName = info.Name
}
toolArgs = info.Args
}
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
}
}
}
}
// handleSessionInfoCommand shows session statistics.
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
@@ -2887,6 +3082,13 @@ func cancelTimerCmd() tea.Cmd {
// Interactive prompt support
// --------------------------------------------------------------------------
// shareResultMsg carries the result of an async gist upload.
type shareResultMsg struct {
err error
gistURL string
viewerURL string
}
// 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().
@@ -3151,9 +3353,19 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
var displayHiddenCount int
if displayOutput != "" {
lines := strings.Split(displayOutput, "\n")
// Cap individual line length to prevent long lines from wrapping
// into excessive visual rows.
maxLineChars := max(m.width*3, 200)
for i, line := range lines {
if len(line) > maxLineChars {
lines[i] = line[:maxLineChars] + "…"
}
}
if len(lines) > maxShellDisplayLines {
displayHiddenCount = len(lines) - maxShellDisplayLines
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
} else {
displayOutput = strings.Join(lines, "\n")
}
}
+63 -13
View File
@@ -12,7 +12,9 @@ import (
// Stored at ~/.config/kit/preferences.yml, separate from the declarative
// .kit.yml config so we never clobber user comments or formatting.
type preferences struct {
Theme string `yaml:"theme,omitempty"`
Theme string `yaml:"theme,omitempty"`
Model string `yaml:"model,omitempty"`
ThinkingLevel string `yaml:"thinking_level,omitempty"`
}
// preferencesPath returns ~/.config/kit/preferences.yml.
@@ -25,28 +27,28 @@ func preferencesPath() string {
return filepath.Join(cfgDir, "kit", "preferences.yml")
}
// LoadThemePreference reads the persisted theme name from preferences.yml.
// Returns "" if no preference is saved or the file doesn't exist.
func LoadThemePreference() string {
// loadPreferences reads and parses the preferences file.
// Returns zero-value preferences if the file is missing or invalid.
func loadPreferences() preferences {
path := preferencesPath()
if path == "" {
return ""
return preferences{}
}
data, err := os.ReadFile(path)
if err != nil {
return ""
return preferences{}
}
var prefs preferences
if err := yaml.Unmarshal(data, &prefs); err != nil {
return ""
return preferences{}
}
return strings.TrimSpace(prefs.Theme)
return prefs
}
// SaveThemePreference persists the theme name to ~/.config/kit/preferences.yml.
// Preserves other preference fields. Uses atomic write (temp + rename) to
// avoid corruption from concurrent Kit instances.
func SaveThemePreference(name string) error {
// savePreferences atomically writes the preferences file, merging into any
// existing content. The mutate function receives the current preferences and
// should modify them in place.
func savePreferences(mutate func(*preferences)) error {
path := preferencesPath()
if path == "" {
return nil // silently skip if config dir unavailable
@@ -58,7 +60,7 @@ func SaveThemePreference(name string) error {
_ = yaml.Unmarshal(data, &prefs)
}
prefs.Theme = name
mutate(&prefs)
data, err := yaml.Marshal(&prefs)
if err != nil {
@@ -77,3 +79,51 @@ func SaveThemePreference(name string) error {
}
return os.Rename(tmp, path)
}
// ── Theme preference ────────────────────────────────────────────────────────
// LoadThemePreference reads the persisted theme name from preferences.yml.
// Returns "" if no preference is saved or the file doesn't exist.
func LoadThemePreference() string {
return strings.TrimSpace(loadPreferences().Theme)
}
// SaveThemePreference persists the theme name to ~/.config/kit/preferences.yml.
// Preserves other preference fields. Uses atomic write (temp + rename) to
// avoid corruption from concurrent Kit instances.
func SaveThemePreference(name string) error {
return savePreferences(func(p *preferences) {
p.Theme = name
})
}
// ── Model preference ────────────────────────────────────────────────────────
// LoadModelPreference reads the persisted model string (e.g.
// "anthropic/claude-sonnet-4-5-20250929") from preferences.yml.
// Returns "" if no preference is saved.
func LoadModelPreference() string {
return strings.TrimSpace(loadPreferences().Model)
}
// SaveModelPreference persists the model string to preferences.yml.
func SaveModelPreference(model string) error {
return savePreferences(func(p *preferences) {
p.Model = model
})
}
// ── Thinking level preference ───────────────────────────────────────────────
// LoadThinkingLevelPreference reads the persisted thinking level from
// preferences.yml. Returns "" if no preference is saved.
func LoadThinkingLevelPreference() string {
return strings.TrimSpace(loadPreferences().ThinkingLevel)
}
// SaveThinkingLevelPreference persists the thinking level to preferences.yml.
func SaveThinkingLevelPreference(level string) error {
return savePreferences(func(p *preferences) {
p.ThinkingLevel = level
})
}
+90
View File
@@ -88,3 +88,93 @@ func TestSaveThemePreference_PreservesOtherFields(t *testing.T) {
t.Fatalf("expected %q, got %q", "catppuccin", got)
}
}
func TestSaveAndLoadModelPreference(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Initially empty.
if got := LoadModelPreference(); got != "" {
t.Fatalf("expected empty, got %q", got)
}
// Save a model.
if err := SaveModelPreference("anthropic/claude-sonnet-4-5-20250929"); err != nil {
t.Fatalf("SaveModelPreference: %v", err)
}
if got := LoadModelPreference(); got != "anthropic/claude-sonnet-4-5-20250929" {
t.Fatalf("expected %q, got %q", "anthropic/claude-sonnet-4-5-20250929", got)
}
// Overwrite.
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
t.Fatalf("SaveModelPreference: %v", err)
}
if got := LoadModelPreference(); got != "openai/gpt-4o" {
t.Fatalf("expected %q, got %q", "openai/gpt-4o", got)
}
}
func TestSaveAndLoadThinkingLevelPreference(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Initially empty.
if got := LoadThinkingLevelPreference(); got != "" {
t.Fatalf("expected empty, got %q", got)
}
// Save a level.
if err := SaveThinkingLevelPreference("medium"); err != nil {
t.Fatalf("SaveThinkingLevelPreference: %v", err)
}
if got := LoadThinkingLevelPreference(); got != "medium" {
t.Fatalf("expected %q, got %q", "medium", got)
}
// Overwrite.
if err := SaveThinkingLevelPreference("high"); err != nil {
t.Fatalf("SaveThinkingLevelPreference: %v", err)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("expected %q, got %q", "high", got)
}
}
func TestPreferencesPreserveEachOther(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Save all three preferences.
if err := SaveThemePreference("dracula"); err != nil {
t.Fatal(err)
}
if err := SaveModelPreference("anthropic/claude-haiku-3-5-20241022"); err != nil {
t.Fatal(err)
}
if err := SaveThinkingLevelPreference("high"); err != nil {
t.Fatal(err)
}
// All three should be preserved.
if got := LoadThemePreference(); got != "dracula" {
t.Fatalf("theme: expected %q, got %q", "dracula", got)
}
if got := LoadModelPreference(); got != "anthropic/claude-haiku-3-5-20241022" {
t.Fatalf("model: expected %q, got %q", "anthropic/claude-haiku-3-5-20241022", got)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("thinking_level: expected %q, got %q", "high", got)
}
// Updating one should not affect the others.
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
t.Fatal(err)
}
if got := LoadThemePreference(); got != "dracula" {
t.Fatalf("theme after model update: expected %q, got %q", "dracula", got)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("thinking_level after model update: expected %q, got %q", "high", got)
}
}
+35 -17
View File
@@ -14,6 +14,7 @@ import (
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
udiff "github.com/aymanbagabas/go-udiff"
xansi "github.com/charmbracelet/x/ansi"
)
// Maximum visible lines per tool type before truncation.
@@ -322,6 +323,8 @@ func renderLsBody(toolResult string, width int) string {
var result []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)
}
@@ -431,7 +434,8 @@ func renderCodeBlock(content, fileName string, width int) string {
// If this line has no line number, it's a metadata/footer line (e.g. truncation notice).
if p.lineNum == "" {
// Render footer lines with code background but no gutter
footer := codeStyle.Width(codeWidth).Render(p.code)
truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1)
footer := codeStyle.Width(codeWidth).Render(truncatedFooter)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer))
continue
@@ -445,6 +449,9 @@ func renderCodeBlock(content, fileName string, width int) string {
} else {
codePart = p.code
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := codeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
@@ -528,6 +535,9 @@ func renderWriteBlock(content, fileName string, width int) string {
} else {
codePart = line
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := writeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
@@ -578,9 +588,16 @@ func renderBashBody(toolResult string, width int) string {
}
const lineIndent = " "
// Truncate individual lines to the available width so they never wrap.
// This mirrors Crush's approach: truncate, don't wrap.
lineWidth := max(width-len(lineIndent), 20)
// Account for PaddingLeft(1) on the output/stderr styles
maxLineChars := lineWidth - 1
var rendered []string
inStderr := false
for _, line := range lines {
line = truncateLine(line, maxLineChars)
// Detect the STDERR: label that Kit's bash tool emits
if strings.TrimSpace(line) == "STDERR:" {
inStderr = true
@@ -682,23 +699,28 @@ func syntaxHighlight(source, fileName string) string {
// Helpers
// ---------------------------------------------------------------------------
// padRight pads s with spaces to exactly width characters.
// padRight pads s with spaces to exactly width visual characters.
// This is ANSI-aware: it measures the visual width of s (ignoring escape
// codes and accounting for wide characters) before padding or truncating.
func padRight(s string, width int) string {
if len(s) >= width {
return s[:width]
w := xansi.StringWidth(s)
if w >= width {
return xansi.Truncate(s, width, "")
}
return s + strings.Repeat(" ", width-len(s))
return s + strings.Repeat(" ", width-w)
}
// truncateLine truncates a line to maxWidth, adding "…" if truncated.
// 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.
func truncateLine(s string, maxWidth int) string {
if len(s) <= maxWidth {
if xansi.StringWidth(s) <= maxWidth {
return s
}
if maxWidth < 2 {
return s[:maxWidth]
return xansi.Truncate(s, maxWidth, "")
}
return s[:maxWidth-1] + "…"
return xansi.Truncate(s, maxWidth, "…")
}
// ---------------------------------------------------------------------------
@@ -858,12 +880,10 @@ func renderBashCompact(toolResult string, width int) string {
display = display[:maxLines]
}
// Truncate each line to available width
// Truncate each line to available width (ANSI-aware)
lineMax := max(width-4, 20)
for i, line := range display {
if len(line) > lineMax {
display[i] = line[:lineMax-3] + "..."
}
display[i] = truncateLine(line, lineMax)
}
summary := strings.Join(display, "\n")
@@ -940,10 +960,8 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
continue
}
// Truncate long lines
if len(trimmed) > maxWidth {
trimmed = trimmed[:maxWidth-3] + "..."
}
// Truncate long lines (ANSI-aware)
trimmed = truncateLine(trimmed, maxWidth)
preview = append(preview, trimmed)
if len(preview) >= maxLines {
+25
View File
@@ -71,3 +71,28 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
Timeout: 5 * time.Minute,
})
```
### Real-time subagent events
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `spawn_subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
switch ev := event.(type) {
case kit.MessageUpdateEvent:
fmt.Print(ev.Chunk) // streaming text from child
case kit.ToolCallEvent:
fmt.Printf("Child calling: %s\n", ev.ToolName)
case kit.ToolResultEvent:
fmt.Printf("Child result: %s\n", ev.ToolName)
}
})
}
})
```
The listener receives the same event types as `Subscribe()` (`ToolCallEvent`, `MessageUpdateEvent`, `ReasoningDeltaEvent`, etc.) but scoped to the child agent's activity. Listeners are cleaned up automatically when the subagent completes.
If no listeners are registered for a tool call, no event dispatching overhead is incurred.
+8 -1
View File
@@ -75,10 +75,17 @@ These commands are available inside the Kit TUI during an interactive session:
| `/tree` | Navigate session tree |
| `/fork` | Branch from an earlier message |
| `/new` | Start a new session |
| `/name` | Set session display name |
| `/name [name]` | Set or show session display name |
| `/resume` | Open session picker to switch sessions (alias: `/r`) |
| `/session` | Show session info |
| `/export [path]` | Export session as JSONL (default: auto-generated path) |
| `/import <path>` | Import a session from a JSONL file |
| `/quit` | Exit Kit |
### Prompt history
Use **↑** and **↓** arrow keys to navigate through previously submitted prompts. Kit keeps the last 100 entries. Consecutive duplicates are skipped.
## ACP server
Run Kit as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server. ACP-compatible clients communicate with Kit over JSON-RPC 2.0 on stdin/stdout.
+22
View File
@@ -113,3 +113,25 @@ host.OnAfterTurn(0, func(ctx context.Context) error {
```
The first argument is a priority (lower = runs first).
## Subagent event monitoring
Monitor real-time events from LLM-initiated subagents (when the model uses the `spawn_subagent` tool):
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
// Receives the same event types as Subscribe(), scoped to the child agent
switch ev := event.(type) {
case kit.MessageUpdateEvent:
fmt.Print(ev.Chunk)
case kit.ToolCallEvent:
fmt.Printf("Subagent calling: %s\n", ev.ToolName)
}
})
}
})
```
`SubscribeSubagent` returns an unsubscribe function. Listeners are also cleaned up automatically when the subagent completes. See [Subagents](/advanced/subagents) for more details.
+17
View File
@@ -39,6 +39,8 @@ kit --resume
kit -r
```
The session picker supports search, scope/filter toggles (all sessions vs. current directory), and session deletion. You can also open it during a session with the `/resume` slash command.
### Open a specific session
```bash
@@ -46,6 +48,21 @@ kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
```
## Session commands
These slash commands are available during an interactive session:
| Command | Description |
|---------|-------------|
| `/name [name]` | Set or display the session's display name |
| `/session` | Show session info (path, ID, message count) |
| `/resume` | Open the session picker to switch sessions |
| `/export [path]` | Export session as JSONL (auto-generates path if omitted) |
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/new` | Start a fresh session |
## Ephemeral mode
Run without creating a session file:
+12
View File
@@ -19,6 +19,8 @@ Switch themes at runtime with the `/theme` command:
Run `/theme` with no arguments to list all available themes.
**Theme selections are automatically saved** to `~/.config/kit/preferences.yml` and restored on next launch. You don't need to add anything to your config file — just `/theme <name>` and it sticks.
## Built-in themes
| Theme | Style |
@@ -276,4 +278,14 @@ When multiple sources define the same theme name, later sources win:
3. Project-local themes (`.kit/themes/`)
4. Extension-registered themes (highest)
### Startup theme resolution
At startup, Kit determines which theme to apply:
1. **`.kit.yml` `theme:` key** — explicit config always wins (highest priority)
2. **`~/.config/kit/preferences.yml`** — persisted `/theme` selection
3. **Default `kitt` theme** — fallback
The preferences file is updated automatically whenever you use `/theme` or `ctx.SetTheme()`. It is separate from `.kit.yml` so it never clobbers your config comments or formatting.
Theme changes via `/theme` or `ctx.SetTheme()` take effect immediately on all UI elements, including previously rendered messages.
File diff suppressed because it is too large Load Diff