feat: add session management features (picker, history, /name, /export, /import)

Fill session management gaps compared to pi:
- TUI session picker (/resume, --resume flag) with search, scope/filter
  toggles, delete, right-aligned metadata, and background-highlighted cursor
- Prompt history navigation via up/down arrows (100-entry ring buffer)
- /name <name> command now functional (was stubbed as not implemented)
- /export [path] exports session JSONL to file
- /import <path> loads session from JSONL file
- Remove deprecated unused App.runQueueItem method
This commit is contained in:
Ed Zynda
2026-03-20 18:08:48 +03:00
parent 131ce8f2cc
commit 4e66c0b4f7
7 changed files with 899 additions and 57 deletions
+29 -9
View File
@@ -678,11 +678,16 @@ func runNormalMode(ctx context.Context) error {
},
}
if resumeFlag {
// TODO: TUI session picker.
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
// When --resume is combined with interactive mode, the TUI session
// picker will be shown at startup. For non-interactive mode, fall
// back to auto-selecting the most recent session.
if positionalPrompt != "" {
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
}
}
// Interactive mode: ShowSessionPicker is set below on AppModelOptions.
}
kitInstance, err := kit.New(ctx, kitOpts)
@@ -1081,9 +1086,21 @@ func runNormalMode(ctx context.Context) error {
return kitInstance.SetThinkingLevel(context.Background(), level)
}
// Build session-switching callback. Opens a JSONL session file and
// replaces the active tree session on both the Kit SDK and App layer.
switchSessionForUI := func(path string) error {
ts, err := kit.OpenTreeSession(path)
if err != nil {
return fmt.Errorf("failed to open session: %w", err)
}
kitInstance.SetTreeSession(ts)
appInstance.SwitchTreeSession(ts)
return nil
}
// 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, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
}
// Quiet mode is not allowed in interactive mode
@@ -1091,7 +1108,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, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1104,7 +1121,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, 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) 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 []ui.ExtensionCommand, 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 {
// Expand @file references in the prompt before sending to the agent.
if cwd, err := os.Getwd(); err == nil {
prompt = ui.ProcessFileAttachments(prompt, cwd)
@@ -1147,7 +1164,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, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
}
return nil
@@ -1245,7 +1262,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, 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) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, 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 {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1254,6 +1271,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
}
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
@@ -1286,6 +1304,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ShowSessionPicker: resumeFlag,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+16 -38
View File
@@ -217,6 +217,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
}
// SwitchTreeSession replaces the active tree session with a new one and
// reloads the in-memory message store from the new session's messages.
// The old tree session is closed. Used by /resume to switch sessions.
func (a *App) SwitchTreeSession(ts *session.TreeManager) {
// Close old session.
if old := a.opts.TreeSession; old != nil {
_ = old.Close()
}
a.opts.TreeSession = ts
// Reload messages from new session.
a.store.Clear()
if ts != nil {
a.store.Replace(ts.GetFantasyMessages())
}
}
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
@@ -483,44 +499,6 @@ func (a *App) runQueueBatch(items []queueItem) {
a.sendEvent(StepCompleteEvent{ResponseText: result.Response})
}
// runQueueItem executes a single queue item: adds the user message to the store,
// runs the agent step, and sends the appropriate event to the program.
// Deprecated: Use runQueueBatch which handles both single and multiple items.
func (a *App) runQueueItem(item queueItem) {
// Create a per-step cancellable context.
stepCtx, cancel := context.WithCancel(a.rootCtx)
a.mu.Lock()
a.cancelStep = cancel
a.mu.Unlock()
defer cancel()
// Build event function that sends to the registered tea.Program (if any).
a.mu.Lock()
prog := a.program
a.mu.Unlock()
eventFn := func(msg tea.Msg) {
if prog != nil {
prog.Send(msg)
}
}
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
if err != nil {
if stepCtx.Err() != nil {
// Step was cancelled by the user (e.g. double-ESC). Send a
// cancellation event so the TUI can cut off the response
// cleanly without printing an error.
a.sendEvent(StepCancelledEvent{})
return
}
a.sendEvent(StepErrorEvent{Err: err})
return
}
a.sendEvent(StepCompleteEvent{ResponseText: result.Response})
}
// --------------------------------------------------------------------------
// Internal: single agent step
// --------------------------------------------------------------------------
+16
View File
@@ -141,6 +141,22 @@ var SlashCommands = []SlashCommand{
Description: "Set a display name for this session",
Category: "Navigation",
},
{
Name: "/resume",
Description: "Open session picker to switch sessions",
Category: "Navigation",
Aliases: []string{"/r"},
},
{
Name: "/export",
Description: "Export session (JSONL by default, or /export path.jsonl)",
Category: "System",
},
{
Name: "/import",
Description: "Import a session from a JSONL file (/import path.jsonl)",
Category: "System",
},
{
Name: "/session",
Description: "Show session info and statistics",
+89
View File
@@ -68,8 +68,26 @@ 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
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
// skipped. Empty strings are never stored.
history []string
// historyIndex is the current position when browsing history.
// When not browsing, historyIndex == len(history).
historyIndex int
// savedInput holds the user's in-progress text before they started
// browsing history, so it can be restored when they press down past
// the end of history.
savedInput string
// browsingHistory is true when the user is navigating history with
// up/down arrows. Set to false when they type a character or submit.
browsingHistory bool
}
// maxHistory is the maximum number of prompt entries kept in history.
const maxHistory = 100
// clipboardImageMsg is the result of an async clipboard image read.
type clipboardImageMsg struct {
image *ImageAttachment
@@ -138,6 +156,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.submitNext {
s.submitNext = false
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.showPopup = false
@@ -166,10 +185,47 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+d", "enter":
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.lastValue = ""
return s, s.handleSubmit(value)
case "up":
// Navigate prompt history backward (older entries).
if len(s.history) > 0 {
if !s.browsingHistory {
// Start browsing — save current input.
s.savedInput = s.textarea.Value()
s.browsingHistory = true
s.historyIndex = len(s.history)
}
if s.historyIndex > 0 {
s.historyIndex--
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
}
return s, nil
}
case "down":
// Navigate prompt history forward (newer entries).
if s.browsingHistory {
if s.historyIndex < len(s.history)-1 {
s.historyIndex++
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
} else {
// Past the end — restore saved input.
s.historyIndex = len(s.history)
s.browsingHistory = false
s.textarea.SetValue(s.savedInput)
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
s.savedInput = ""
}
return s, nil
}
case "ctrl+v":
// Try to read an image from the clipboard asynchronously.
return s, readClipboardImageCmd()
@@ -250,6 +306,11 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// User typed something — exit history browsing mode.
if s.browsingHistory {
s.browsingHistory = false
s.savedInput = ""
}
lines := strings.Split(value, "\n")
line := lines[len(lines)-1] // current line (last line for multi-line)
@@ -372,6 +433,34 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
}
}
// pushHistory adds a prompt to the history ring buffer. Empty strings and
// consecutive duplicates of the last entry are skipped. When the buffer
// exceeds maxHistory, the oldest entry is dropped.
func (s *InputComponent) pushHistory(value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
// Skip consecutive duplicates.
if len(s.history) > 0 && s.history[len(s.history)-1] == trimmed {
s.resetHistoryBrowsing()
return
}
s.history = append(s.history, trimmed)
if len(s.history) > maxHistory {
s.history = s.history[len(s.history)-maxHistory:]
}
s.resetHistoryBrowsing()
}
// resetHistoryBrowsing resets the history browsing state so the index
// points past the end (ready for new input).
func (s *InputComponent) resetHistoryBrowsing() {
s.historyIndex = len(s.history)
s.browsingHistory = false
s.savedInput = ""
}
// View implements tea.Model. Renders the title, textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
+208 -10
View File
@@ -44,6 +44,9 @@ const (
// stateModelSelector means the /model selector overlay is active.
stateModelSelector
// stateSessionSelector means the /resume session picker is active.
stateSessionSelector
)
// AppController is the interface the parent TUI model uses to interact with the
@@ -330,6 +333,16 @@ type AppModelOptions struct {
// May be nil if extensions are not loaded.
EmitModelChange func(newModel, previousModel, source string)
// SwitchSession opens a session by JSONL file path, replacing the
// active tree session and reloading messages. Called when the user
// picks a session from /resume. May be nil if session switching is
// not supported.
SwitchSession func(path string) error
// ShowSessionPicker, when true, opens the session picker immediately
// on startup (used by --resume flag).
ShowSessionPicker bool
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
ThinkingLevel string
// IsReasoningModel is true when the current model supports reasoning.
@@ -497,6 +510,13 @@ type AppModel struct {
// modelSelector is the model selection overlay, active in stateModelSelector.
modelSelector *ModelSelectorComponent
// sessionSelector is the session picker overlay, active in stateSessionSelector.
sessionSelector *SessionSelectorComponent
// switchSession opens a session by JSONL path, replacing the active session.
// Wired from cmd/root.go.
switchSession func(path string) error
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
@@ -630,6 +650,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.thinkingVisible = true // default to showing thinking blocks
m.isReasoningModel = opts.IsReasoningModel
m.setThinkingLevel = opts.SetThinkingLevel
m.switchSession = opts.SwitchSession
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -660,6 +681,12 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
m.stream.SetThinkingVisible(m.thinkingVisible)
// If --resume was passed, open the session picker immediately.
if opts.ShowSessionPicker {
m.sessionSelector = NewSessionSelector(opts.Cwd, width, height)
m.state = stateSessionSelector
}
// Propagate initial height distribution to children.
m.distributeHeight()
@@ -868,6 +895,33 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
return m, nil
// ── Session selector events ──────────────────────────────────────────────
case SessionSelectedMsg:
m.sessionSelector = nil
m.state = stateInput
if m.switchSession != nil {
if err := m.switchSession(msg.Path); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch session: %v", err))
} else {
m.printSystemMessage("Session loaded. Continue where you left off.")
}
} else {
m.printSystemMessage("Session switching not available.")
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case SessionSelectorCancelledMsg:
m.sessionSelector = nil
m.state = stateInput
return m, nil
case SessionDeletedMsg:
// Session was deleted from picker — just show a message.
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
// ── Window resize ────────────────────────────────────────────────────────
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -951,6 +1005,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// Route to session selector when active.
if m.state == stateSessionSelector && m.sessionSelector != nil {
updated, cmd := m.sessionSelector.Update(msg)
m.sessionSelector = updated.(*SessionSelectorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch msg.String() {
case "esc":
if m.state == stateWorking {
@@ -1065,6 +1127,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/name":
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/export":
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/import":
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
}
@@ -1467,6 +1547,11 @@ func (m *AppModel) View() tea.View {
return m.modelSelector.View()
}
// Session selector overlay replaces the normal layout.
if m.state == stateSessionSelector && m.sessionSelector != nil {
return m.sessionSelector.View()
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
@@ -1888,7 +1973,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/new":
return m.handleNewCommand()
case "/name":
return m.handleNameCommand()
return m.handleNameCommand("")
case "/resume":
return m.handleResumeCommand()
case "/export":
return m.handleExportCommand("")
case "/import":
return m.handleImportCommand("")
case "/session":
return m.handleSessionInfoCommand()
@@ -1990,10 +2081,14 @@ func (m *AppModel) printHelpMessage() {
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new`: Start a new branch (preserves history)\n\n" +
"- `/new`: Start a new branch (preserves history)\n" +
"- `/resume`: Open session picker to switch sessions\n" +
"- `/name <name>`: Set a display name for this session\n\n" +
"**System:**\n" +
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
"- `/clear`: Clear message history\n" +
"- `/export [path]`: Export session as JSONL\n" +
"- `/import <path.jsonl>`: Import session from JSONL file\n" +
"- `/reset-usage`: Reset usage statistics\n" +
"- `/quit`: Exit the application\n\n"
@@ -2621,21 +2716,124 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
// Usage: /name <new name> — sets the session name.
//
// /name — shows the current name.
func (m *AppModel) handleNameCommand(args string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
if args == "" {
// No argument — show current name.
currentName := ts.GetSessionName()
if currentName != "" {
m.printSystemMessage(fmt.Sprintf("Session name: %q\nTo rename: `/name <new name>`", currentName))
} else {
m.printSystemMessage("Session has no name. Set one with: `/name <new name>`")
}
return nil
}
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
// Set the session name.
if _, err := ts.AppendSessionInfo(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to set session name: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session named %q", args))
return nil
}
// handleExportCommand exports the current session to a file.
// Usage: /export — copies the JSONL file to cwd with a descriptive name.
//
// /export path.jsonl — copies to the specified path.
func (m *AppModel) handleExportCommand(args string) 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 export.")
return nil
}
// Determine destination path.
dstPath := args
if dstPath == "" {
// Generate a name based on session name or ID.
name := ts.GetSessionName()
if name == "" {
name = ts.GetSessionID()[:12]
}
// Sanitize for filename.
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == ' ' {
return '_'
}
return r
}, name)
dstPath = fmt.Sprintf("session_%s.jsonl", name)
}
// Copy the file.
data, err := os.ReadFile(srcPath)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
return nil
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to write export file: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session exported to: %s (%d bytes)", dstPath, len(data)))
return nil
}
// handleImportCommand imports a session from a JSONL file.
// Usage: /import path.jsonl
func (m *AppModel) handleImportCommand(args string) tea.Cmd {
if args == "" {
m.printSystemMessage("Usage: `/import <path.jsonl>`")
return nil
}
if m.switchSession == nil {
m.printSystemMessage("Session switching is not available.")
return nil
}
// Verify file exists before attempting to switch.
if _, err := os.Stat(args); err != nil {
m.printSystemMessage(fmt.Sprintf("File not found: %s", args))
return nil
}
if err := m.switchSession(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to import session: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session imported from: %s", args))
return nil
}
// handleResumeCommand opens the session picker so the user can switch sessions.
func (m *AppModel) handleResumeCommand() tea.Cmd {
if m.switchSession == nil {
m.printSystemMessage("Session switching is not available.")
return nil
}
m.sessionSelector = NewSessionSelector(m.cwd, m.width, m.height)
m.state = stateSessionSelector
return nil
}
+535
View File
@@ -0,0 +1,535 @@
package ui
import (
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/session"
)
// SessionSelectedMsg is sent when the user selects a session from the picker.
type SessionSelectedMsg struct {
Path string // absolute path to the JSONL session file
}
// SessionSelectorCancelledMsg is sent when the user cancels the picker.
type SessionSelectorCancelledMsg struct{}
// SessionDeletedMsg is sent after a session is deleted so the parent can
// react (e.g. print a message).
type SessionDeletedMsg struct {
Name string
}
// SessionScopeMode controls which sessions are shown.
type SessionScopeMode int
const (
SessionScopeCwd SessionScopeMode = iota // current folder only
SessionScopeAll // all sessions across projects
)
func (m SessionScopeMode) String() string {
if m == SessionScopeAll {
return "All"
}
return "Current Folder"
}
// SessionFilterMode controls filtering of the session list.
type SessionFilterMode int
const (
SessionFilterAll SessionFilterMode = iota // show all sessions
SessionFilterNamed // only named sessions
)
func (m SessionFilterMode) String() string {
if m == SessionFilterNamed {
return "Named"
}
return "All"
}
// controlCharsRe matches ASCII control characters for stripping from previews.
var controlCharsRe = regexp.MustCompile(`[\x00-\x1f\x7f]`)
// SessionSelectorComponent is a full-screen Bubble Tea component that lets
// the user browse and select from available sessions. Modeled after pi's
// session picker: right-aligned metadata, background-highlighted selection,
// scope/filter toggles, and inline search.
type SessionSelectorComponent struct {
allSessions []session.SessionInfo
cwdSessions []session.SessionInfo
filtered []session.SessionInfo
cursor int
search string
scope SessionScopeMode
filter SessionFilterMode
// currentPath is the active session file path for marking it in the list.
currentPath string
width int
height int
active bool
// confirmDelete is non-negative when a delete confirmation is pending.
confirmDelete int
}
// NewSessionSelector creates a session selector. It loads sessions for the
// current working directory and all sessions across projects. If cwd is
// empty, only "All" scope is available.
func NewSessionSelector(cwd string, width, height int) *SessionSelectorComponent {
ss := &SessionSelectorComponent{
width: width,
height: height,
active: true,
confirmDelete: -1,
}
// Load sessions (errors are swallowed — empty list is fine).
if cwd != "" {
ss.cwdSessions, _ = session.ListSessions(cwd)
ss.scope = SessionScopeCwd
}
ss.allSessions, _ = session.ListAllSessions()
if cwd == "" || len(ss.cwdSessions) == 0 {
ss.scope = SessionScopeAll
}
ss.rebuildFiltered()
return ss
}
// SetCurrentPath sets the currently active session path so the picker can
// highlight it in the list.
func (ss *SessionSelectorComponent) SetCurrentPath(path string) {
ss.currentPath = path
}
// Init implements tea.Model.
func (ss *SessionSelectorComponent) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
ss.width = msg.Width
ss.height = msg.Height
return ss, nil
case tea.KeyPressMsg:
// Delete confirmation mode.
if ss.confirmDelete >= 0 {
switch msg.String() {
case "y", "Y":
idx := ss.confirmDelete
ss.confirmDelete = -1
if idx < len(ss.filtered) {
info := ss.filtered[idx]
if err := session.DeleteSession(info.Path); err == nil {
name := sessionDisplayName(info)
ss.removeSession(info.Path)
ss.rebuildFiltered()
return ss, func() tea.Msg {
return SessionDeletedMsg{Name: name}
}
}
}
return ss, nil
default:
ss.confirmDelete = -1
return ss, nil
}
}
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
if ss.cursor > 0 {
ss.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
if ss.cursor < len(ss.filtered)-1 {
ss.cursor++
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
ss.cursor -= ss.visibleHeight()
if ss.cursor < 0 {
ss.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
ss.cursor += ss.visibleHeight()
if ss.cursor >= len(ss.filtered) {
ss.cursor = len(ss.filtered) - 1
}
if ss.cursor < 0 {
ss.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
ss.cursor = 0
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
ss.cursor = max(len(ss.filtered)-1, 0)
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if ss.cursor < len(ss.filtered) {
info := ss.filtered[ss.cursor]
ss.active = false
return ss, func() tea.Msg {
return SessionSelectedMsg{Path: info.Path}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if ss.search != "" {
ss.search = ""
ss.rebuildFiltered()
} else {
ss.active = false
return ss, func() tea.Msg {
return SessionSelectorCancelledMsg{}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if ss.scope == SessionScopeCwd {
ss.scope = SessionScopeAll
} else {
ss.scope = SessionScopeCwd
}
ss.rebuildFiltered()
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))):
if ss.filter == SessionFilterAll {
ss.filter = SessionFilterNamed
} else {
ss.filter = SessionFilterAll
}
ss.rebuildFiltered()
case key.Matches(msg, key.NewBinding(key.WithKeys("d"))):
if ss.cursor < len(ss.filtered) {
ss.confirmDelete = ss.cursor
}
return ss, nil
default:
if msg.Text != "" && len(msg.Text) == 1 {
ch := msg.Text[0]
if ch >= 32 && ch < 127 {
ss.search += string(ch)
ss.rebuildFiltered()
}
}
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ss.search) > 0 {
ss.search = ss.search[:len(ss.search)-1]
ss.rebuildFiltered()
}
}
}
return ss, nil
}
// View implements tea.Model.
func (ss *SessionSelectorComponent) View() tea.View {
theme := GetTheme()
w := ss.width
var b 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")
// ── 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"))
} else {
b.WriteString(helpStyle.Render("tab N D esc"))
}
b.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")
}
b.WriteString("\n")
// ── Delete confirmation ──────────────────────────────────────
if ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) {
warnStyle := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).PaddingLeft(1)
name := sessionDisplayName(ss.filtered[ss.confirmDelete])
b.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40))))
b.WriteString("\n")
}
// ── Session list ─────────────────────────────────────────────
if len(ss.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
if ss.search != "" {
b.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."))
} else if ss.scope == SessionScopeCwd {
b.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all."))
} else {
b.WriteString(emptyStyle.Render("No sessions found"))
}
b.WriteString("\n")
} else {
visH := ss.visibleHeight()
// Center the cursor in the visible window.
startIdx := max(0, min(ss.cursor-visH/2, len(ss.filtered)-visH))
endIdx := min(startIdx+visH, len(ss.filtered))
for i := startIdx; i < endIdx; i++ {
info := ss.filtered[i]
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")
}
// 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")
}
}
return tea.NewView(b.String())
}
// IsActive returns whether the selector is still accepting input.
func (ss *SessionSelectorComponent) IsActive() bool {
return ss.active
}
// --- Internal helpers ---
func (ss *SessionSelectorComponent) visibleHeight() int {
// Reserve: title(1) + help(1) + blank(1) + scroll indicator(1) = 4.
// Optional: search(1), delete confirm(1).
chrome := 4
if ss.search != "" {
chrome++
}
if ss.confirmDelete >= 0 {
chrome++
}
return max(ss.height-chrome, 3)
}
func (ss *SessionSelectorComponent) rebuildFiltered() {
var source []session.SessionInfo
if ss.scope == SessionScopeCwd {
source = ss.cwdSessions
} else {
source = ss.allSessions
}
if ss.filter == SessionFilterNamed {
var named []session.SessionInfo
for _, s := range source {
if s.Name != "" {
named = append(named, s)
}
}
source = named
}
if ss.search != "" {
query := strings.ToLower(ss.search)
var matches []session.SessionInfo
for _, s := range source {
haystack := strings.ToLower(s.Name + " " + s.FirstMessage + " " + s.Cwd)
if strings.Contains(haystack, query) {
matches = append(matches, s)
}
}
ss.filtered = matches
} else {
ss.filtered = source
}
if ss.cursor >= len(ss.filtered) {
ss.cursor = max(len(ss.filtered)-1, 0)
}
}
func (ss *SessionSelectorComponent) removeSession(path string) {
ss.cwdSessions = removeByPath(ss.cwdSessions, path)
ss.allSessions = removeByPath(ss.allSessions, path)
}
func removeByPath(sessions []session.SessionInfo, path string) []session.SessionInfo {
result := make([]session.SessionInfo, 0, len(sessions))
for _, s := range sessions {
if s.Path != path {
result = append(result, s)
}
}
return result
}
// 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()
// ── Cursor indicator (2 chars) ───────────────────────────────
cursorStr := " "
if isCursor {
cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render(" ")
}
const cursorW = 2
// ── Right part: message count + relative time (+ optional cwd) ──
age := relativeTime(info.Modified)
msgCount := fmt.Sprintf("%d", info.MessageCount)
rightPart := msgCount + " " + age
if ss.scope == SessionScopeAll && info.Cwd != "" {
shortCwd := shortenPath(info.Cwd)
if len(shortCwd) > 25 {
shortCwd = "..." + shortCwd[len(shortCwd)-22:]
}
rightPart = shortCwd + " " + rightPart
}
rightW := utf8.RuneCountInString(rightPart)
// ── Message text ─────────────────────────────────────────────
displayText := sessionDisplayName(info)
// Strip control characters and collapse whitespace.
displayText = controlCharsRe.ReplaceAllString(displayText, " ")
displayText = strings.Join(strings.Fields(displayText), " ")
availableForMsg := max(width-cursorW-rightW-2, 10) // 2 for min spacing
displayText = truncateRunes(displayText, availableForMsg)
msgW := utf8.RuneCountInString(displayText)
// ── Style the message ────────────────────────────────────────
msgStyle := lipgloss.NewStyle()
switch {
case isDeleting:
msgStyle = msgStyle.Foreground(theme.Error)
case isCurrent:
msgStyle = msgStyle.Foreground(theme.Accent)
case info.Name != "":
msgStyle = msgStyle.Foreground(theme.Warning)
default:
msgStyle = msgStyle.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)
// ── Assemble with spacing ────────────────────────────────────
spacing := max(width-cursorW-msgW-rightW, 1)
line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight
// ── Background highlight for selected row ────────────────────
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)
}
return line
}
// --- Package helpers ---
// sessionDisplayName returns the best display string for a session:
// the name if set, the first message, or a fallback.
func sessionDisplayName(info session.SessionInfo) string {
if info.Name != "" {
return info.Name
}
if info.FirstMessage != "" {
return info.FirstMessage
}
return "(empty session)"
}
// truncateRunes truncates a string to at most maxRunes runes, appending "..."
// if truncated.
func truncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
if maxRunes <= 3 {
return string(runes[:maxRunes])
}
return string(runes[:maxRunes-1]) + "…"
}
// shortenPath replaces the user's home directory prefix with ~.
func shortenPath(path string) string {
return tildeHome(path)
}
// relativeTime formats a time as a short relative string like "5m", "2h", "3d".
func relativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd", int(d.Hours()/24))
case d < 30*24*time.Hour:
return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
case d < 365*24*time.Hour:
return fmt.Sprintf("%dmo", int(d.Hours()/(24*30)))
default:
return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
}
}
+6
View File
@@ -34,6 +34,12 @@ func DeleteSession(path string) error {
return session.DeleteSession(path)
}
// OpenTreeSession opens an existing JSONL session file. This is a package-level
// function (no Kit instance required) used by the CLI for session switching.
func OpenTreeSession(path string) (*TreeManager, error) {
return session.OpenTreeSession(path)
}
// --- Instance methods on Kit ---
// GetTreeSession returns the tree session manager, or nil if not configured.