mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-20 14:20:34 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d24540693c | |||
| f7c8e7757b | |||
| 0d5374b17b | |||
| 25f17a104d | |||
| 20125f939b | |||
| d3b67ffd14 | |||
| 915dc066dd |
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user