mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc94e60ccd | ||
|
|
a439a1ed0d | ||
|
|
34eb2d49c3 | ||
|
|
5a0fefddd6 | ||
|
|
a2235112e5 |
No files matched your search
@@ -195,7 +195,7 @@ Useful controls:
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
@@ -205,6 +205,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
|
||||
@@ -72,8 +72,11 @@ func preflightExecSession(options execOptions) error {
|
||||
if session == nil {
|
||||
return execUsageError{"Zero session not found: " + options.resume}
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return execUsageError{"Zero session is not resumable: " + options.resume}
|
||||
}
|
||||
case options.resumeLatest:
|
||||
latest, err := store.Latest()
|
||||
latest, err := store.LatestResumable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestPreflightRejectsSideSessions(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide}); err != nil {
|
||||
t.Fatalf("create side session: %v", err)
|
||||
}
|
||||
|
||||
err := preflightExecSession(execOptions{resumeLatest: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "No Zero sessions available to resume") {
|
||||
t.Fatalf("preflightExecSession error = %v, want no resumable sessions", err)
|
||||
}
|
||||
|
||||
err = preflightExecSession(execOptions{resume: "side"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: side") {
|
||||
t.Fatalf("explicit side-session preflight error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -216,10 +216,10 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
|
||||
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch kind {
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSide, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
return kind, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, side, spec-draft, or spec-impl.", value)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
|
||||
--kind <kind> Filter list by fork, child, side, spec-draft, or spec-impl
|
||||
--sequence <n> Rewind target sequence (rewind-plan, rewind)
|
||||
--event <id> Rewind target event id (rewind-plan, rewind)
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
|
||||
@@ -233,6 +233,9 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -249,6 +252,17 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
t.Fatalf("filtered sessions output = %q", output)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
|
||||
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
||||
|
||||
@@ -89,7 +89,7 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if resumeID != "" || options.ResumeLatest {
|
||||
sessionID := resumeID
|
||||
if sessionID == "" && options.ResumeLatest {
|
||||
latest, err := store.Latest()
|
||||
latest, err := store.LatestResumable()
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
}
|
||||
@@ -105,6 +105,9 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if session == nil {
|
||||
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
|
||||
}
|
||||
if !IsResumableKind(session.SessionKind) {
|
||||
return PreparedExec{}, ExecError{"Zero session is not resumable: " + sessionID}
|
||||
}
|
||||
contextEvents, err := readExecContextEvents(store, session.SessionID)
|
||||
if err != nil {
|
||||
return PreparedExec{}, err
|
||||
|
||||
+20
-10
@@ -52,6 +52,7 @@ type SessionKind string
|
||||
const (
|
||||
SessionKindFork SessionKind = "fork"
|
||||
SessionKindChild SessionKind = "child"
|
||||
SessionKindSide SessionKind = "side"
|
||||
SessionKindSpecDraft SessionKind = "spec-draft"
|
||||
SessionKindSpecImpl SessionKind = "spec-impl"
|
||||
)
|
||||
@@ -125,11 +126,13 @@ type CreateInput struct {
|
||||
}
|
||||
|
||||
type ForkInput struct {
|
||||
SessionID string
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
SessionID string
|
||||
SessionKind SessionKind
|
||||
Title string
|
||||
Cwd string
|
||||
ModelID string
|
||||
Provider string
|
||||
Tag string
|
||||
}
|
||||
|
||||
type ChildInput struct {
|
||||
@@ -354,10 +357,9 @@ func (store *Store) Latest() (*Metadata, error) {
|
||||
}
|
||||
|
||||
// IsResumableKind reports whether a session kind represents a standalone,
|
||||
// user-resumable conversation rather than an agent sub-run. Regular ("") and
|
||||
// user fork sessions are resumable; child (specialist/sub-agent) and spec
|
||||
// draft/impl sessions are not — each agent task and /spec run creates one, so
|
||||
// listing them in the resume picker floods it with non-conversation entries.
|
||||
// user-resumable conversation rather than a transient or agent-owned sub-run.
|
||||
// Regular ("") and user fork sessions are resumable; side, child, and spec
|
||||
// sessions are not, so they do not flood the resume picker.
|
||||
func IsResumableKind(kind SessionKind) bool {
|
||||
switch kind {
|
||||
case "", SessionKindFork:
|
||||
@@ -418,13 +420,21 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if title == "" && parent.Title != "" {
|
||||
title = parent.Title + " (fork)"
|
||||
}
|
||||
kind := input.SessionKind
|
||||
if kind == "" {
|
||||
kind = SessionKindFork
|
||||
}
|
||||
if kind != SessionKindFork && kind != SessionKindSide {
|
||||
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
|
||||
}
|
||||
fork, err := store.Create(CreateInput{
|
||||
SessionID: input.SessionID,
|
||||
SessionKind: SessionKindFork,
|
||||
SessionKind: kind,
|
||||
Title: title,
|
||||
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
|
||||
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
|
||||
Provider: firstNonEmpty(input.Provider, parent.Provider),
|
||||
Tag: input.Tag,
|
||||
ParentSessionID: parent.SessionID,
|
||||
RootSessionID: firstNonEmpty(parent.RootSessionID, parent.SessionID),
|
||||
ForkedFromEventID: last.ID,
|
||||
|
||||
@@ -181,6 +181,32 @@ func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSupportsNonResumableSideSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create parent: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "context"}}); err != nil {
|
||||
t.Fatalf("AppendEvent: %v", err)
|
||||
}
|
||||
|
||||
side, err := store.Fork(parent.SessionID, ForkInput{
|
||||
SessionID: "side",
|
||||
SessionKind: SessionKindSide,
|
||||
Tag: "btw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fork side: %v", err)
|
||||
}
|
||||
if side.SessionKind != SessionKindSide || side.Tag != "btw" || side.ParentSessionID != parent.SessionID {
|
||||
t.Fatalf("side metadata = %#v", side)
|
||||
}
|
||||
if IsResumableKind(side.SessionKind) {
|
||||
t.Fatal("side sessions must not appear as resumable conversations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreForkSkipsUsageEvents(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
|
||||
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
|
||||
@@ -381,6 +407,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
mk(SessionKindSpecImpl, "spec-impl-1")
|
||||
newestResumable := mk("", "conversation-2") // newest standalone conversation
|
||||
mk(SessionKindChild, "child-2") // newer overall, but a sub-run
|
||||
mk(SessionKindSide, "side-1") // newer overall, but non-resumable
|
||||
|
||||
resumable, err := store.ListResumable()
|
||||
if err != nil {
|
||||
@@ -404,7 +431,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
|
||||
if latest != nil {
|
||||
got = latest.SessionID
|
||||
}
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip the newer child)", got, newestResumable.SessionID)
|
||||
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip newer child and side sessions)", got, newestResumable.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +562,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if _, err := store.AppendEvent("latest", AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "previous answer"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(CreateInput{SessionID: "newer-side", SessionKind: SessionKindSide}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := PrepareExec(PrepareExecOptions{Store: store, ResumeLatest: true})
|
||||
if err != nil {
|
||||
@@ -546,6 +576,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
if got := FormatExecPrompt("continue", prepared); got == "continue" || !strings.Contains(got, "previous answer") {
|
||||
t.Fatalf("expected session context in prompt, got %q", got)
|
||||
}
|
||||
if _, err := PrepareExec(PrepareExecOptions{Store: store, Resume: "newer-side"}); err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: newer-side") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
|
||||
forked, err := PrepareExec(PrepareExecOptions{Store: store, Fork: "latest", SessionID: "forked"})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
)
|
||||
|
||||
const (
|
||||
btwSessionTag = "btw"
|
||||
// btwRunIDGap keeps the hidden parent's current run below the first side run.
|
||||
// A parent cannot start another user turn while its surface is hidden; the
|
||||
// monotonic btwRunIDSeq separately prevents collisions across BTW re-entry.
|
||||
btwRunIDGap = 1_000_000
|
||||
|
||||
btwContextBoundary = `You are in an isolated side conversation. The inherited session history is reference context only.
|
||||
Do not continue or complete earlier tasks, plans, tool calls, approvals, or requests. Only instructions submitted after this boundary are active.
|
||||
Answer questions and explore lightly. Do not modify workspace state unless the user explicitly asks for that mutation in this side conversation.
|
||||
Nothing from this side conversation will be merged into the main session.`
|
||||
)
|
||||
|
||||
// btwState keeps the main surface alive while an isolated side conversation is
|
||||
// visible. The parent model continues receiving its own run messages, but its
|
||||
// transcript and session events stay hidden until the user returns.
|
||||
type btwState struct {
|
||||
active bool
|
||||
parent *model
|
||||
sideRunIDBase int
|
||||
parentNeedsInput bool
|
||||
}
|
||||
|
||||
func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
question = strings.TrimSpace(question)
|
||||
if m.btw.active {
|
||||
if question != "" {
|
||||
return m.appendSystemNotice("A BTW conversation is already active. Run /btw with no question to return to the main session."), nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("Compaction is running. Wait for it to finish before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.exiting {
|
||||
return m.appendSystemNotice("Zero is waiting for the current run to finish saving before exit. A BTW conversation cannot start now."), nil
|
||||
}
|
||||
if m.activeSession.SessionID == "" {
|
||||
return m.appendSystemNotice("Start the main session with a prompt before opening a BTW conversation."), nil
|
||||
}
|
||||
if m.sessionStore == nil {
|
||||
return m.appendSystemNotice("BTW conversation unavailable: no session store configured."), nil
|
||||
}
|
||||
|
||||
fork, err := m.sessionStore.Fork(m.activeSession.SessionID, sessions.ForkInput{
|
||||
SessionKind: sessions.SessionKindSide,
|
||||
Title: btwTitle(m.activeSession.Title),
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
Tag: btwSessionTag,
|
||||
})
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not open BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
events, err := m.resumeEvents(fork.SessionID)
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Could not load BTW conversation: " + err.Error()), nil
|
||||
}
|
||||
|
||||
// Foreground loops are session-scoped. Stop them only after the side session
|
||||
// is ready, so a failed fork cannot destroy work in the main session.
|
||||
parent := m
|
||||
clearedLoops := 0
|
||||
if updated, cleared := parent.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
parent = updated
|
||||
clearedLoops = cleared
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: fmt.Sprintf("Stopped %d loop(s) before opening the BTW conversation.", cleared),
|
||||
})
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
// A scrollback print that was already scheduled may acknowledge after the
|
||||
// side surface is active. The hidden model does not receive that unscoped
|
||||
// acknowledgement, so clear its print latch and rebuild on return.
|
||||
parent.printInFlight = false
|
||||
parent.flushQueue = nil
|
||||
|
||||
side := parent
|
||||
side.activeSession = fork
|
||||
side.sessionEvents = events
|
||||
side.transcript = initialTranscript()
|
||||
side.transcript = appendRow(side.transcript, rowSystem, "BTW conversation · isolated from the main session · /btw or Ctrl+C to return")
|
||||
if clearedLoops > 0 {
|
||||
side.transcript = appendRow(side.transcript, rowSystem, fmt.Sprintf("Stopped %d main-session loop(s) before opening this conversation.", clearedLoops))
|
||||
}
|
||||
side.transcript = appendTranscriptRowsDedup(side.transcript, transcriptRowsFromSessionEvents(events))
|
||||
side.printInFlight = false
|
||||
side.flushQueue = nil
|
||||
side.resetFlushFrontier("· btw conversation ·")
|
||||
side.pending = false
|
||||
side.exiting = false
|
||||
side.runCancel = nil
|
||||
side.activeRunID = 0
|
||||
side.runID = maxInt(parent.runID+btwRunIDGap, parent.btwRunIDSeq)
|
||||
side.flushRunIDs = map[int]string{}
|
||||
side.liveUsageCounts = map[int]int{}
|
||||
side.usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &side.modelCatalog, Now: side.now})
|
||||
side.lastUsage = usage.Normalized{}
|
||||
side.lastUsageSeen = false
|
||||
side.unpricedRequests = 0
|
||||
side.unpricedTokens = 0
|
||||
side.compactRequests = 0
|
||||
side.compactFrame = 0
|
||||
side.lastCompactResult = nil
|
||||
side.lastCompactError = ""
|
||||
side.turnLatencySum = 0
|
||||
side.turnLatencyCount = 0
|
||||
side.turnTTFTSum = 0
|
||||
side.turnTTFTCount = 0
|
||||
side.pendingPermission = nil
|
||||
side.pendingAskUser = nil
|
||||
side.pendingSpecReview = nil
|
||||
side.queuedMessage = ""
|
||||
side.lastPrompt = ""
|
||||
side.lastImages = nil
|
||||
side.lastImageLabels = nil
|
||||
side.lastDocuments = nil
|
||||
side.inputHistory = nil
|
||||
side.historyIdx = 0
|
||||
side.historyDraft = ""
|
||||
if question == "" {
|
||||
side.pendingImages = nil
|
||||
side.pendingImageLabels = nil
|
||||
side.pendingDocuments = nil
|
||||
}
|
||||
side.loops = nil
|
||||
side.activeLoopID = ""
|
||||
side.loopTicking = false
|
||||
side.specialists.clear()
|
||||
side.plan.clear()
|
||||
side.planDetailGen++
|
||||
side.streamingText = nil
|
||||
side.streamingReasoning = ""
|
||||
side.streamingReasoningExpanded = false
|
||||
side.clearStreamingToolCall()
|
||||
side.resetStreamingFade()
|
||||
side.btw = btwState{
|
||||
active: true,
|
||||
parent: &parent,
|
||||
sideRunIDBase: side.runID,
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return side, nil
|
||||
}
|
||||
return side.launchPrompt(question)
|
||||
}
|
||||
|
||||
func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
return m.appendSystemNotice("A BTW response is still running. Press Esc twice to cancel it, or wait for it to finish before returning."), nil
|
||||
}
|
||||
if len(m.flushRunIDs) > 0 {
|
||||
return m.appendSystemNotice("The cancelled BTW response is still saving its session events. Wait for it to finish before returning."), nil
|
||||
}
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("BTW compaction is still running. Wait for it to finish before returning."), nil
|
||||
}
|
||||
m, _ = m.clearLoopsForSessionSwitch()
|
||||
parent := *m.btw.parent
|
||||
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
|
||||
parent.btw = btwState{}
|
||||
// A hidden parent completion may have scheduled an unscoped git-sweep result
|
||||
// that landed on the side surface. Re-run it after restoring the parent so
|
||||
// its FILES sidebar cannot stay permanently marked in-flight or stale.
|
||||
parent.gitSweepInFlight = false
|
||||
var sweepCmd tea.Cmd
|
||||
parent, sweepCmd = parent.maybeGitSweep()
|
||||
// The side surface owns its own spinner tick chain. If the hidden parent is
|
||||
// still active, explicitly restart that chain after restoring it.
|
||||
parent.spinnerTicking = false
|
||||
var spinnerCmd tea.Cmd
|
||||
if parent.pending || parent.compactInFlight || parent.doctorInFlight || parent.sidebarHasAgents() || parent.aimlapiOnboardAnimating() {
|
||||
parent.spinnerTicking = true
|
||||
spinnerCmd = parent.spinner.Tick
|
||||
}
|
||||
parent.transcript = reduceTranscript(parent.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
|
||||
})
|
||||
parent.resetFlushFrontier("· returned from btw ·")
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd)
|
||||
}
|
||||
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
switch command.kind {
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
|
||||
commandRewind, commandCompact, commandSTTModel, commandMCP:
|
||||
return true
|
||||
case commandModel:
|
||||
return arg != "list" && arg != "ls"
|
||||
case commandProvider:
|
||||
return arg != "status"
|
||||
case commandTurns, commandProfile:
|
||||
return arg != "" && arg != "status"
|
||||
case commandTheme:
|
||||
return arg != "list"
|
||||
case commandConfig:
|
||||
return arg != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resizeBTWParent carries terminal geometry into the hidden parent. Window-size
|
||||
// messages have no run ID, so normal BTW routing intentionally leaves them on
|
||||
// the visible side surface; copying the layout fields prevents stale wrapping
|
||||
// after the parent is restored.
|
||||
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m
|
||||
}
|
||||
parent := *m.btw.parent
|
||||
parent.width = msg.Width
|
||||
parent.height = msg.Height
|
||||
parent = parent.clearHover()
|
||||
parent.lineAges = nil
|
||||
parent.lastStreamActivity = parent.now()
|
||||
parent.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
m.btw.parent = &parent
|
||||
return m
|
||||
}
|
||||
|
||||
func btwTitle(parent string) string {
|
||||
parent = strings.TrimSpace(parent)
|
||||
if parent == "" {
|
||||
return "BTW conversation"
|
||||
}
|
||||
return parent + " (BTW)"
|
||||
}
|
||||
|
||||
// routeBTWParentMessage lets a main-session run finish while the side surface is
|
||||
// active. Side run IDs start far above the captured parent's counter, so messages
|
||||
// below that boundary unambiguously belong to the hidden parent model.
|
||||
func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil, false
|
||||
}
|
||||
if title, ok := msg.(sessionTitleGeneratedMsg); ok {
|
||||
if title.sessionID != m.btw.parent.activeSession.SessionID {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
runID, ok := btwMessageRunID(msg)
|
||||
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
|
||||
return m, nil, false
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
|
||||
func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
parentNext, cmd := m.btw.parent.updateModel(msg)
|
||||
parent, ok := parentNext.(model)
|
||||
if !ok {
|
||||
return m, cmd, true
|
||||
}
|
||||
parent.btw = btwState{}
|
||||
m.btw.parent = &parent
|
||||
switch msg.(type) {
|
||||
case permissionRequestMsg, askUserRequestMsg:
|
||||
if !m.btw.parentNeedsInput {
|
||||
m.btw.parentNeedsInput = true
|
||||
m = m.appendSystemNotice("The main session needs your input. Return with /btw or Ctrl+C to respond.")
|
||||
}
|
||||
case agentResponseMsg:
|
||||
m.btw.parentNeedsInput = parent.pendingPermission != nil || parent.pendingAskUser != nil
|
||||
}
|
||||
return m, cmd, true
|
||||
}
|
||||
|
||||
func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
switch typed := msg.(type) {
|
||||
case agentTextMsg:
|
||||
return typed.runID, true
|
||||
case agentReasoningMsg:
|
||||
return typed.runID, true
|
||||
case agentUsageMsg:
|
||||
return typed.runID, true
|
||||
case agentResponseMsg:
|
||||
return typed.runID, true
|
||||
case agentRowMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamStartMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamDeltaMsg:
|
||||
return typed.runID, true
|
||||
case planUpdateMsg:
|
||||
return typed.runID, true
|
||||
case specialistStartMsg:
|
||||
return typed.runID, true
|
||||
case specialistCompleteMsg:
|
||||
return typed.runID, true
|
||||
case specialistProgressMsg:
|
||||
return typed.runID, true
|
||||
case swarmSessionsMsg:
|
||||
return typed.runID, true
|
||||
case permissionRequestMsg:
|
||||
return typed.runID, true
|
||||
case askUserRequestMsg:
|
||||
return typed.runID, true
|
||||
case recapGeneratedMsg:
|
||||
return typed.runID, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func newBTWTestModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(sessions.CreateInput{
|
||||
SessionID: "main-session",
|
||||
Title: "Main task",
|
||||
Cwd: "/repo",
|
||||
ModelID: "test-model",
|
||||
Provider: "test-provider",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent session: %v", err)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"role": "user", "content": "implement the main task"},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
} {
|
||||
if _, err := store.AppendEvent(parent.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: payload}); err != nil {
|
||||
t.Fatalf("append parent event: %v", err)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(parent.SessionID)
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load parent session: session=%#v err=%v", loaded, err)
|
||||
}
|
||||
events, err := store.ReadEvents(parent.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: "/repo",
|
||||
ModelName: "test-model",
|
||||
ProviderName: "test-provider",
|
||||
SessionStore: store,
|
||||
})
|
||||
m.activeSession = *loaded
|
||||
m.sessionEvents = events
|
||||
m.transcript = appendTranscriptRowsDedup(initialTranscript(), transcriptRowsFromSessionEvents(events))
|
||||
return m
|
||||
}
|
||||
|
||||
func TestBTWCommandParsesInlineQuestion(t *testing.T) {
|
||||
got := parseCommand("/btw double-check the approach")
|
||||
if got.kind != commandBTW || got.text != "double-check the approach" {
|
||||
t.Fatalf("parseCommand(/btw ...) = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCreatesIsolatedForkAndReturnsWithoutMerge(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
parentEventCount := len(m.sessionEvents)
|
||||
|
||||
side, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("bare /btw should not start an agent run")
|
||||
}
|
||||
if !side.btw.active || side.btw.parent == nil {
|
||||
t.Fatal("expected active BTW state with saved parent")
|
||||
}
|
||||
if side.activeSession.SessionKind != sessions.SessionKindSide || side.activeSession.ParentSessionID != parentID {
|
||||
t.Fatalf("side metadata = %#v", side.activeSession)
|
||||
}
|
||||
if side.activeSession.Tag != btwSessionTag {
|
||||
t.Fatalf("side tag = %q, want %q", side.activeSession.Tag, btwSessionTag)
|
||||
}
|
||||
if len(side.sessionEvents) <= parentEventCount {
|
||||
t.Fatalf("side should contain copied context plus fork marker: got %d events", len(side.sessionEvents))
|
||||
}
|
||||
if !strings.Contains(side.sessionPrompt("question"), btwContextBoundary) {
|
||||
t.Fatal("side prompt is missing the inherited-context boundary")
|
||||
}
|
||||
|
||||
updated, err := side.appendSessionEvent(sessions.EventMessage, map[string]any{
|
||||
"role": "assistant", "content": "side-only answer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append side event: %v", err)
|
||||
}
|
||||
returned, _ := updated.leaveBTW()
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
if len(returned.sessionEvents) != parentEventCount {
|
||||
t.Fatalf("side events merged into parent: got %d events, want %d", len(returned.sessionEvents), parentEventCount)
|
||||
}
|
||||
parentEvents, err := returned.sessionStore.ReadEvents(parentID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent after return: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "side-only answer") {
|
||||
t.Fatal("side-only event was persisted into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.runID = 7
|
||||
m.activeRunID = 7
|
||||
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.pending || side.activeRunID != 0 {
|
||||
t.Fatalf("side inherited parent run state: pending=%v activeRunID=%d", side.pending, side.activeRunID)
|
||||
}
|
||||
if side.btw.parent == nil || !side.btw.parent.pending || side.btw.parent.activeRunID != 7 {
|
||||
t.Fatalf("parent run was not preserved: %#v", side.btw.parent)
|
||||
}
|
||||
|
||||
routed, _, ok := side.routeBTWParentMessage(agentTextMsg{runID: 7, delta: "main progress"})
|
||||
if !ok {
|
||||
t.Fatal("parent run message was not routed")
|
||||
}
|
||||
if strings.Contains(routed.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output leaked into the side transcript")
|
||||
}
|
||||
if routed.btw.parent == nil || !strings.Contains(routed.btw.parent.streamingTextString(), "main progress") {
|
||||
t.Fatal("parent streaming output was not retained on the hidden parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side, cmd := m.handleBTWCommand("double-check this assumption")
|
||||
if cmd == nil || !side.pending {
|
||||
t.Fatalf("inline /btw question did not start a run: pending=%v cmd=%v", side.pending, cmd)
|
||||
}
|
||||
if side.lastPrompt != "double-check this assumption" {
|
||||
t.Fatalf("side last prompt = %q", side.lastPrompt)
|
||||
}
|
||||
if side.btw.parent == nil || side.btw.parent.pending {
|
||||
t.Fatal("idle parent should remain idle while the side run starts")
|
||||
}
|
||||
parentEvents, err := side.sessionStore.ReadEvents(side.btw.parent.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read parent events: %v", err)
|
||||
}
|
||||
for _, event := range parentEvents {
|
||||
if strings.Contains(string(event.Payload), "double-check this assumption") {
|
||||
t.Fatal("inline side question was written into the parent session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsBeforeMainSessionStarts(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})})
|
||||
got, cmd := m.handleBTWCommand("")
|
||||
if cmd != nil {
|
||||
t.Fatal("pre-session /btw should not start work")
|
||||
}
|
||||
if got.btw.active || !transcriptContains(got.transcript, "Start the main session") {
|
||||
t.Fatalf("unexpected pre-session /btw result: active=%v transcript=%#v", got.btw.active, got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksCommandsThatWouldReplaceItsSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand("/new"))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("/new replaced the active BTW session: active=%v metadata=%#v", got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("missing blocked-command guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWBlocksPersistentConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model other",
|
||||
"/provider add",
|
||||
"/turns 100",
|
||||
"/profile fast",
|
||||
"/theme dark",
|
||||
"/config recaps off",
|
||||
"/stt-model",
|
||||
"/mcp",
|
||||
"/rewind",
|
||||
"/compact",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if !got.btw.active || got.activeSession.SessionKind != sessions.SessionKindSide {
|
||||
t.Fatalf("%s escaped the active BTW session: active=%v metadata=%#v", input, got.btw.active, got.activeSession)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s missing blocked-command guidance: %#v", input, got.transcript)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWAllowsReadOnlyConfigurationCommands(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/model list",
|
||||
"/provider status",
|
||||
"/turns",
|
||||
"/profile status",
|
||||
"/theme list",
|
||||
"/config",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
updated, _ := side.dispatchCommand(parseCommand(input))
|
||||
got := updated.(model)
|
||||
if transcriptContains(got.transcript, "unavailable in a BTW conversation") {
|
||||
t.Fatalf("%s was blocked even though it is read-only", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWExitBlockedWhileParentRunActive(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/exit"))
|
||||
got := updated.(model)
|
||||
if cmd != nil || got.exiting || !got.btw.active {
|
||||
t.Fatalf("/exit escaped BTW while parent was active: cmd=%v exiting=%v active=%v", cmd, got.exiting, got.btw.active)
|
||||
}
|
||||
if got.btw.parent == nil || !got.btw.parent.pending {
|
||||
t.Fatalf("hidden parent run was not preserved: %#v", got.btw.parent)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "main session is still running") {
|
||||
t.Fatalf("missing active-parent exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWReturnRestartsParentSpinner(t *testing.T) {
|
||||
for _, reducedMotion := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "animated", true: "reduced-motion"}[reducedMotion], func(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.reducedMotion = reducedMotion
|
||||
m.pending = true
|
||||
m.spinnerTicking = true
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.spinnerTicking = false
|
||||
|
||||
returned, cmd := side.leaveBTW()
|
||||
if !returned.pending || !returned.spinnerTicking {
|
||||
t.Fatalf("parent spinner was not restarted: pending=%v ticking=%v", returned.pending, returned.spinnerTicking)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("returning to an active parent did not schedule a spinner tick")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCancelledRunFlushesBeforeReturnAndRunIDsStayUnique(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.provider = &fakeProvider{}
|
||||
|
||||
side1, _ := m.handleBTWCommand("first side question")
|
||||
firstRunID := side1.activeRunID
|
||||
side1.cancelRun()
|
||||
blocked, _ := side1.leaveBTW()
|
||||
if !blocked.btw.active || !transcriptContains(blocked.transcript, "still saving its session events") {
|
||||
t.Fatal("BTW returned before the cancelled side run finished flushing")
|
||||
}
|
||||
|
||||
flushedModel, _ := side1.updateModel(agentResponseMsg{runID: firstRunID})
|
||||
flushed := flushedModel.(model)
|
||||
if len(flushed.flushRunIDs) != 0 {
|
||||
t.Fatalf("cancelled side run did not drain: %#v", flushed.flushRunIDs)
|
||||
}
|
||||
parent, _ := flushed.leaveBTW()
|
||||
side2, _ := parent.handleBTWCommand("second side question")
|
||||
if side2.activeRunID == firstRunID {
|
||||
t.Fatalf("BTW run ID was reused across re-entry: %d", firstRunID)
|
||||
}
|
||||
|
||||
updated, _ := side2.updateModel(agentResponseMsg{
|
||||
runID: firstRunID,
|
||||
rows: []transcriptRow{{kind: rowAssistant, text: "answer to the FIRST question"}},
|
||||
})
|
||||
got := updated.(model)
|
||||
if !got.pending || transcriptContains(got.transcript, "answer to the FIRST question") {
|
||||
t.Fatal("a stale side response hijacked the next BTW run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsExplicitResumeOfSideSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if _, err := m.resolveResumeSession(side.activeSession.SessionID); err == nil || !strings.Contains(err.Error(), "not resumable") {
|
||||
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCannotStartDuringDeferredExit(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.exiting = true
|
||||
|
||||
got, cmd := m.handleBTWCommand("should not run")
|
||||
if cmd != nil || got.btw.active {
|
||||
t.Fatalf("BTW started during deferred exit: active=%v cmd=%v", got.btw.active, cmd)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "cannot start now") {
|
||||
t.Fatalf("missing deferred-exit guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWStatusAndHelpStayVisible(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
if status := plainRender(t, side.statusLine(100)); !strings.Contains(status, "BTW") {
|
||||
t.Fatalf("status line has no persistent BTW indicator: %q", status)
|
||||
}
|
||||
groups := side.buildKeybindingGroups()
|
||||
if got := groups[0].bindings[3].desc; !strings.Contains(got, "return to the main session") {
|
||||
t.Fatalf("BTW Ctrl+C help = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWResizeUpdatesHiddenParent(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, _ := side.updateModel(tea.WindowSizeMsg{Width: 120, Height: 50})
|
||||
got := updated.(model)
|
||||
if got.btw.parent == nil || got.btw.parent.width != 120 || got.btw.parent.height != 50 {
|
||||
t.Fatalf("hidden parent kept stale geometry: %#v", got.btw.parent)
|
||||
}
|
||||
returned, _ := got.leaveBTW()
|
||||
if returned.width != 120 || returned.height != 50 {
|
||||
t.Fatalf("restored parent geometry = %dx%d, want 120x50", returned.width, returned.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWUsesIndependentUsageTracker(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentTracker := m.usageTracker
|
||||
side, _ := m.handleBTWCommand("")
|
||||
if side.usageTracker == nil || side.usageTracker == parentTracker {
|
||||
t.Fatal("BTW conversation shared the main session usage tracker")
|
||||
}
|
||||
returned, _ := side.leaveBTW()
|
||||
if returned.usageTracker != parentTracker {
|
||||
t.Fatal("returning from BTW did not restore the main session usage tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWFailedForkKeepsMainSessionLoops(t *testing.T) {
|
||||
badRoot := filepath.Join(t.TempDir(), "sessions")
|
||||
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: badRoot})})
|
||||
m.activeSession = sessions.Metadata{SessionID: "main-session"}
|
||||
m.loops = []*loopState{{id: "loop-1"}}
|
||||
|
||||
got, _ := m.handleBTWCommand("")
|
||||
if len(got.loops) != 1 || got.loops[0].id != "loop-1" {
|
||||
t.Fatalf("failed BTW fork cleared main-session loops: %#v", got.loops)
|
||||
}
|
||||
if !transcriptContains(got.transcript, "Could not open BTW conversation") {
|
||||
t.Fatalf("missing fork failure notice: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCommandReturnsToParentSession(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
parentID := m.activeSession.SessionID
|
||||
side, _ := m.handleBTWCommand("")
|
||||
|
||||
updated, cmd := side.dispatchCommand(parseCommand("/btw"))
|
||||
returned := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("returning from an idle BTW conversation should not start work")
|
||||
}
|
||||
if returned.btw.active || returned.btw.parent != nil {
|
||||
t.Fatalf("BTW state remained active after /btw: %#v", returned.btw)
|
||||
}
|
||||
if returned.activeSession.SessionID != parentID {
|
||||
t.Fatalf("returned session = %q, want parent %q", returned.activeSession.SessionID, parentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWCtrlCDuringRunDoesNotClearDraft(t *testing.T) {
|
||||
m := newBTWTestModel(t)
|
||||
side, _ := m.handleBTWCommand("")
|
||||
side.pending = true
|
||||
side.input.SetValue("keep this draft")
|
||||
|
||||
updated, cmd := side.handleCtrlC()
|
||||
got := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("Ctrl+C should not start a command while a BTW response is running")
|
||||
}
|
||||
if !got.btw.active {
|
||||
t.Fatal("Ctrl+C returned from BTW while its response was still running")
|
||||
}
|
||||
if got.composerValue() != "keep this draft" {
|
||||
t.Fatalf("Ctrl+C cleared the in-flight BTW draft: %q", got.composerValue())
|
||||
}
|
||||
if !transcriptContains(got.transcript, "BTW response is still running") {
|
||||
t.Fatalf("missing in-flight return guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandBTW
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandVoice
|
||||
@@ -192,6 +193,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/btw",
|
||||
usage: "/btw [question]",
|
||||
group: commandGroupSession,
|
||||
description: "Open an isolated side conversation; run /btw again to return.",
|
||||
kind: commandBTW,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
|
||||
@@ -31,6 +31,10 @@ type keybindingGroup struct {
|
||||
// Sourced from the real key cases in model.go (Update) — not invented. When a
|
||||
// binding is added/changed there, update this method too.
|
||||
func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
ctrlCDescription := "cancel the run, then quit"
|
||||
if m.btw.active {
|
||||
ctrlCDescription = "return to the main session when the BTW run is idle"
|
||||
}
|
||||
return []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
@@ -38,7 +42,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"Enter", "send the message"},
|
||||
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"Ctrl+C", ctrlCDescription},
|
||||
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
|
||||
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
|
||||
@@ -103,6 +103,11 @@ type model struct {
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
btw btwState
|
||||
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
|
||||
// surface. It survives returning to the parent so a late message from an old
|
||||
// side run can never match a run in a later BTW conversation.
|
||||
btwRunIDSeq int
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -1029,6 +1034,14 @@ func (m model) shutdownLSPManager() {
|
||||
}
|
||||
|
||||
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
|
||||
if m.btw.active {
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
return m.leaveBTW()
|
||||
}
|
||||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
@@ -1108,6 +1121,12 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m = m.resizeBTWParent(size)
|
||||
}
|
||||
if next, cmd, routed := m.routeBTWParentMessage(msg); routed {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case composerBlinkMsg:
|
||||
switch {
|
||||
@@ -4114,6 +4133,21 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
if m.btw.active && command.kind == commandExit && m.btw.parent != nil &&
|
||||
(m.btw.parent.pending || m.btw.parent.compactInFlight || len(m.btw.parent.flushRunIDs) > 0) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "The main session is still running. Return to it first with /btw or Ctrl+C before exiting.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
if m.btw.active && btwCommandUnavailable(command) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: command.name + " is unavailable in a BTW conversation. Return to the main session first with /btw or Ctrl+C.",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
switch command.kind {
|
||||
case commandEmpty:
|
||||
return m, nil
|
||||
@@ -4152,6 +4186,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandBTW:
|
||||
return m.handleBTWCommand(command.text)
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
|
||||
@@ -246,11 +246,15 @@ func (m model) sessionPrompt(prompt string) string {
|
||||
if m.activeSession.SessionID == "" || len(m.sessionEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
return sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
formatted := sessions.FormatExecPrompt(prompt, sessions.PreparedExec{
|
||||
Mode: sessions.ModeResume,
|
||||
Session: m.activeSession,
|
||||
ContextEvents: append([]sessions.Event{}, m.sessionEvents...),
|
||||
})
|
||||
if m.activeSession.SessionKind == sessions.SessionKindSide {
|
||||
return btwContextBoundary + "\n\n" + formatted
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
@@ -276,6 +280,9 @@ func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("zero session not found: %s", args)
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return nil, fmt.Errorf("zero session is not resumable: %s", args)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -195,17 +195,21 @@ func (m model) statusLine(width int) string {
|
||||
// in its mode colour. This was previously only on the easy-to-miss composer
|
||||
// rule; the persistent footer is where users look for "will it run commands?".
|
||||
modeText, modeStyle := m.modeLabel()
|
||||
left := prefix + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
btwChip := ""
|
||||
if m.btw.active {
|
||||
btwChip = zeroTheme.amber.Render("BTW") + zeroTheme.muted.Render(" · ")
|
||||
}
|
||||
left := prefix + btwChip + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||||
|
||||
if tier == tierTiny {
|
||||
if m.exitConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
}
|
||||
if m.cancelConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
return fitStyledLine(prefix+btwChip+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
}
|
||||
if dictation := m.dictationStatusChip(); dictation != "" {
|
||||
return fitStyledLine(prefix+dictation, width)
|
||||
return fitStyledLine(prefix+btwChip+dictation, width)
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
@@ -215,16 +219,16 @@ func (m model) statusLine(width int) string {
|
||||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
|
||||
}
|
||||
if m.exitConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
} else if m.cancelConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
} else if m.dictation.downloading && m.dictation.downloadStatus != "" {
|
||||
// A model download in progress takes over the left chip with a live percentage.
|
||||
left = prefix + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
left = prefix + btwChip + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
|
||||
} else if dictation := m.dictationStatusChip(); dictation != "" && m.dictation.active() {
|
||||
// An active recording/transcription takes over the left chip — it is the
|
||||
// most time-sensitive thing on screen (the mic is live).
|
||||
left = prefix + dictation
|
||||
left = prefix + btwChip + dictation
|
||||
} else {
|
||||
if voice := m.voiceModeIndicator(); voice != "" {
|
||||
left += zeroTheme.muted.Render(" · ") + voice
|
||||
|
||||
Reference in new issue
Block a user