diff --git a/internal/app/autopilot.go b/internal/app/autopilot.go index cb3c7ea7..d67ca403 100644 --- a/internal/app/autopilot.go +++ b/internal/app/autopilot.go @@ -17,7 +17,6 @@ import ( "go.uber.org/zap" "github.com/genai-io/san/internal/app/conv" - "github.com/genai-io/san/internal/app/input" "github.com/genai-io/san/internal/app/kit" "github.com/genai-io/san/internal/core" "github.com/genai-io/san/internal/llm" @@ -154,37 +153,35 @@ func parseAutoPilot(s string) setting.AutoPilotSettings { return a } -// missionBriefingPrompt is the copilot's persona while the user briefs it in the -// /autopilot Mission dialog. It replies as the "co-pilot" that will steer the -// session — acknowledging the mission and stating, briefly, how it will drive. -const missionBriefingPrompt = `You are the autopilot copilot riding shotgun on a coding agent — a second set of hands that steers the session at set points (approving gray-zone tool calls, answering prompts, deciding whether to keep the agent going after a turn). +// missionRefinePrompt turns the /autopilot Mission dialog into a mission editor: +// a later message refines the running draft instead of piling on. The reply is +// the mission itself, so the prompt forbids any preamble or commentary. +const missionRefinePrompt = `You are helping the user craft the mission for an autonomous coding session — the single directive the autopilot copilot will steer toward. -The user is briefing you on the mission for this session. Reply in 2-4 sentences: confirm the goal in your own words, and state concretely how you will steer toward it — when you will handle things yourself versus hand back to the human, and what would make you stop. Be direct and specific. Do not use lists or headers. If the briefing is ambiguous, ask one sharp clarifying question instead of guessing.` +You are given the current mission draft and the user's new instruction. Return the UPDATED mission: fold the instruction into the draft, keep everything still relevant, tighten and clarify, and drop nothing the user still wants. If the draft is empty, turn the instruction into a clear, self-contained mission. -// missionReply produces the copilot's reply to the briefing so far. It runs on -// the configured autopilot model (falling back to the session model) and returns -// a short acknowledgement of how it will steer. Wired into the /autopilot panel -// via SetMissionResponder. -func (m *model) missionReply(ctx context.Context, history []input.MissionMessage) (string, error) { +Return ONLY the mission text — no preamble, no quotes, no commentary. Write it as a direct instruction to the agent: concrete, actionable, a few sentences at most.` + +// missionRefine folds the user's new instruction into the running mission draft +// and returns the improved mission. It runs on the configured autopilot model +// (falling back to the session model). Wired into the /autopilot panel via +// SetMissionRefiner. +func (m *model) missionRefine(ctx context.Context, current, instruction string) (string, error) { provider, modelID := m.resolveReviewerModel(m.env.AutoPilot.Model) if provider == nil { return "", fmt.Errorf("no model connected") } - msgs := make([]core.Message, 0, len(history)) - for _, h := range history { - role := core.RoleUser - if !h.FromUser { - role = core.RoleAssistant - } - msgs = append(msgs, core.Message{Role: role, Content: h.Text}) + user := "User's instruction:\n" + instruction + if strings.TrimSpace(current) != "" { + user = "Current mission draft:\n" + current + "\n\n" + user } resp, err := llm.Complete(ctx, provider, llm.CompletionOptions{ Model: modelID, - SystemPrompt: missionBriefingPrompt, - Messages: msgs, - MaxTokens: 400, + SystemPrompt: missionRefinePrompt, + Messages: []core.Message{{Role: core.RoleUser, Content: user}}, + MaxTokens: 600, }) if err != nil { return "", err diff --git a/internal/app/input/autopilot_mission.go b/internal/app/input/autopilot_mission.go index 14cd5c75..57bc7c06 100644 --- a/internal/app/input/autopilot_mission.go +++ b/internal/app/input/autopilot_mission.go @@ -1,8 +1,10 @@ -// Mission dialog: a lightweight two-way briefing where the user tells the -// copilot what to accomplish this session and the copilot confirms how it plans -// to drive. Replies are non-streaming — the panel shows a spinner while the -// injected MissionResponder runs, then appends the whole reply at once. The -// accumulated user turns are the mission text persisted with the config. +// Mission dialog: an evolving mission editor. The first message sets the mission +// verbatim — your words are the mission. Each later message refines the running +// draft rather than piling on: the copilot (an injected MissionRefiner) folds +// the new instruction into the current draft and returns the improved mission, +// shown at once behind a spinner. The current draft is the mission text persisted +// with the config; with no model wired, later messages fold in verbatim so +// briefing still works offline. package input import ( @@ -18,30 +20,26 @@ import ( "github.com/genai-io/san/internal/app/kit" ) -// MissionMessage is one turn in the briefing dialog. -type MissionMessage struct { - FromUser bool - Text string -} - -// MissionResponder produces the copilot's reply to the briefing so far. The app -// injects one backed by the session provider; a nil responder disables live -// replies (the briefing is still saved). -type MissionResponder func(ctx context.Context, history []MissionMessage) (string, error) +// MissionRefiner refines the mission: given the current draft and the user's new +// instruction, it returns the improved mission. The app injects one backed by the +// session model; a nil refiner disables refinement (the instruction is then folded +// in verbatim). +type MissionRefiner func(ctx context.Context, current, instruction string) (string, error) -// MissionReplyMsg carries a copilot reply (or error) back to the panel; the app -// routes it to AutopilotSelector.DeliverMissionReply. -type MissionReplyMsg struct { - Text string - Err error +// MissionRefinedMsg carries the refined mission (or an error) back to the panel; +// the app routes it to AutopilotSelector.DeliverRefinedMission. +type MissionRefinedMsg struct { + Mission string + Err error } type missionDialog struct { - log []MissionMessage - input textarea.Model - spinner spinner.Model - thinking bool - status string // transient notice/error under the composer + draft string // the running mission; the first message sets it, later ones refine it + pendingInstruction string // the instruction currently being refined (shown while it runs) + input textarea.Model + spinner spinner.Model + refining bool + status string // transient notice/error under the composer } func newMissionDialog() missionDialog { @@ -53,26 +51,37 @@ func newMissionDialog() missionDialog { return missionDialog{input: ta, spinner: sp} } -// resetMission clears the dialog and seeds it from the persisted mission text so -// re-opening the panel shows the prior briefing as the first user turn. +// resetMission seeds the dialog from the persisted mission so re-opening the +// panel shows the current draft, ready to refine. func (p *AutopilotSelector) resetMission() { - p.mission.log = nil - p.mission.thinking = false + p.mission.draft = strings.TrimSpace(p.snap.Mission) + p.mission.pendingInstruction = "" + p.mission.refining = false p.mission.status = "" p.mission.input.Reset() - if m := strings.TrimSpace(p.snap.Mission); m != "" { - p.mission.log = []MissionMessage{{FromUser: true, Text: m}} - } } -// enterMission focuses and sizes the composer when the view opens. +// enterMission focuses and sizes the composer when the view opens. The width +// leaves room for the focus rail renderComposer prefixes each line with. func (p *AutopilotSelector) enterMission() { - p.mission.input.SetWidth(p.innerWidth()) + p.setMissionPlaceholder() + p.mission.input.SetWidth(p.innerWidth() - missionRailWidth) p.mission.input.SetHeight(3) p.mission.input.Focus() p.mission.input.CursorEnd() } +// setMissionPlaceholder switches the composer hint between "brief" (no mission +// yet) and "refine" (a draft exists), so the empty-composer prompt tracks what +// the next enter will do. Called wherever the draft's presence changes. +func (p *AutopilotSelector) setMissionPlaceholder() { + if p.mission.draft == "" { + p.mission.input.Placeholder = "Brief the copilot: what should it get done this session?" + } else { + p.mission.input.Placeholder = "Refine the mission — e.g. \"also run the tests before pushing\"…" + } +} + func (p *AutopilotSelector) handleMissionKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc": @@ -81,7 +90,7 @@ func (p *AutopilotSelector) handleMissionKey(msg tea.KeyMsg) tea.Cmd { p.view = apMenu return nil case "enter": - return p.sendMission() + return p.submitMission() case "alt+enter", "shift+enter": p.mission.input.InsertString("\n") return nil @@ -95,72 +104,84 @@ func (p *AutopilotSelector) handleMissionKey(msg tea.KeyMsg) tea.Cmd { } } -// sendMission records the user's turn, updates the persisted mission, and (when -// a responder is wired) kicks off the copilot reply behind a spinner. -func (p *AutopilotSelector) sendMission() tea.Cmd { +// submitMission consumes the composer. The first message sets the mission +// verbatim; a later one refines the running draft — via the copilot when a model +// is wired (behind a spinner), or folded in verbatim when it isn't. +func (p *AutopilotSelector) submitMission() tea.Cmd { text := strings.TrimSpace(p.mission.input.Value()) - if text == "" || p.mission.thinking { + if text == "" || p.mission.refining { return nil } - p.mission.log = append(p.mission.log, MissionMessage{FromUser: true, Text: text}) p.mission.input.Reset() - p.commitMission() + p.mission.status = "" - if p.respond == nil { - p.mission.status = "no copilot model available — briefing saved without a reply" + // First message: your words are the mission, verbatim — no round-trip. + if p.mission.draft == "" { + p.mission.draft = text + p.commitMission() + p.setMissionPlaceholder() return nil } - p.mission.thinking = true - p.mission.status = "" - history := append([]MissionMessage(nil), p.mission.log...) - respond := p.respond + + // A later message refines the existing draft. Without a model we can't refine, + // so fold it in as typed. + if p.refine == nil { + p.mission.draft += "\n" + text + p.commitMission() + p.mission.status = "no copilot model — added to the mission as typed" + return nil + } + + p.mission.refining = true + p.mission.pendingInstruction = text + current := p.mission.draft + refine := p.refine request := func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - reply, err := respond(ctx, history) - return MissionReplyMsg{Text: reply, Err: err} + refined, err := refine(ctx, current, text) + return MissionRefinedMsg{Mission: refined, Err: err} } return tea.Batch(p.mission.spinner.Tick, request) } -// clearMission wipes the whole briefing — the accumulated turns and the composer -// — so the mission resets to empty instead of only ever growing; commitMission -// then persists the cleared ("") mission. +// clearMission resets the mission to empty (the draft and the composer) and +// persists the cleared mission. func (p *AutopilotSelector) clearMission() { - p.mission.log = nil - p.mission.thinking = false + p.mission.draft = "" + p.mission.pendingInstruction = "" + p.mission.refining = false p.mission.input.Reset() p.mission.status = "mission cleared" p.commitMission() + p.setMissionPlaceholder() } -// commitMission folds the user turns into the persisted mission directive. +// commitMission persists the current draft as the mission directive. func (p *AutopilotSelector) commitMission() { - var parts []string - for _, m := range p.mission.log { - if m.FromUser { - parts = append(parts, m.Text) - } - } - p.snap.Mission = strings.Join(parts, "\n") + p.snap.Mission = strings.TrimSpace(p.mission.draft) } -// DeliverMissionReply is called by the app when a MissionReplyMsg arrives. -func (p *AutopilotSelector) DeliverMissionReply(text string, err error) { - p.mission.thinking = false +// DeliverRefinedMission is called by the app when a MissionRefinedMsg arrives: +// the refined mission replaces the draft. On error the draft is kept as-is so a +// failed refine never loses the mission. +func (p *AutopilotSelector) DeliverRefinedMission(mission string, err error) { + p.mission.refining = false + p.mission.pendingInstruction = "" if err != nil { p.mission.status = "copilot error: " + err.Error() return } - if t := strings.TrimSpace(text); t != "" { - p.mission.log = append(p.mission.log, MissionMessage{FromUser: false, Text: t}) + if m := strings.TrimSpace(mission); m != "" { + p.mission.draft = m + p.commitMission() } } -// Thinking reports whether the mission dialog is awaiting a reply — the app -// gates spinner ticks on this. -func (p *AutopilotSelector) Thinking() bool { - return p.active && p.view == apMission && p.mission.thinking +// Refining reports whether a mission refine is in flight — the app gates spinner +// ticks on this. +func (p *AutopilotSelector) Refining() bool { + return p.active && p.view == apMission && p.mission.refining } // UpdateSpinner advances the mission spinner and returns its next tick. @@ -171,35 +192,43 @@ func (p *AutopilotSelector) UpdateSpinner(msg tea.Msg) tea.Cmd { } func (p *AutopilotSelector) missionHint() string { - return kit.HintLine(keycap("enter")+" send", keycap("alt+enter")+" newline", keycap("ctrl+r")+" clear", keycap("esc")+" back") + action := "set mission" + if p.mission.draft != "" { + action = "refine" + } + return kit.HintLine(keycap("enter")+" "+action, keycap("alt+enter")+" newline", keycap("ctrl+r")+" clear", keycap("esc")+" back") } func (p *AutopilotSelector) renderMission(width int) string { body := lipgloss.NewStyle().Width(width) var b strings.Builder - if len(p.mission.log) == 0 && !p.mission.thinking { - b.WriteString(apDescStyle.Render("Tell the copilot what to accomplish; it confirms how it plans to drive.")) - b.WriteString("\n\n") - } - for _, m := range p.mission.log { - if m.FromUser { - b.WriteString(missionUserStyle.Render("you")) - } else { - b.WriteString(missionCopilotStyle.Render("⏵ copilot")) - } + + switch { + case p.mission.draft != "": + // The evolving mission, shown in place so each refine reads as an edit. + b.WriteString(missionHeadingStyle.Render("⏵ mission")) b.WriteString("\n") - b.WriteString(body.Render(m.Text)) + b.WriteString(body.Render(p.mission.draft)) + b.WriteString("\n\n") + case !p.mission.refining: + b.WriteString(apDescStyle.Render("Tell the copilot what to accomplish. Your first message is the mission; each one after refines it.")) b.WriteString("\n\n") } - if p.mission.thinking { - b.WriteString(missionCopilotStyle.Render("⏵ copilot")) - b.WriteString("\n") - b.WriteString(p.mission.spinner.View() + " " + apDescStyle.Render("thinking…")) + + if p.mission.refining { + if p.mission.pendingInstruction != "" { + b.WriteString(missionUserStyle.Render("refine")) + b.WriteString("\n") + b.WriteString(body.Render(p.mission.pendingInstruction)) + b.WriteString("\n") + } + b.WriteString(p.mission.spinner.View() + " " + apDescStyle.Render("refining…")) b.WriteString("\n\n") } + b.WriteString(apRuleStyle.Render(strings.Repeat("─", width))) b.WriteString("\n") - b.WriteString(p.mission.input.View()) + b.WriteString(p.renderComposer()) if p.mission.status != "" { b.WriteString("\n") b.WriteString(apDescStyle.Render(p.mission.status)) @@ -207,7 +236,26 @@ func (p *AutopilotSelector) renderMission(width int) string { return b.String() } +// missionRailWidth is the column the focus rail (+ its gap) occupies to the left +// of the composer; enterMission subtracts it so the railed composer still fits. +const missionRailWidth = 2 + +// renderComposer draws the mission textarea with a focus-accent rail down its +// left edge, so the input reads as the active field you type into rather than +// bare text under the divider. Kept deliberately minimal — one thin bar, no box. +func (p *AutopilotSelector) renderComposer() string { + rail := missionRailStyle.Render("▎") + " " + lines := strings.Split(p.mission.input.View(), "\n") + for i, ln := range lines { + lines[i] = rail + ln + } + return strings.Join(lines, "\n") +} + var ( missionUserStyle = lipgloss.NewStyle().Foreground(kit.CurrentTheme.Text).Bold(true) - missionCopilotStyle = lipgloss.NewStyle().Foreground(kit.CurrentTheme.Accent).Bold(true) + missionHeadingStyle = lipgloss.NewStyle().Foreground(kit.CurrentTheme.Accent).Bold(true) + // missionRailStyle is the thin accent bar down the composer's left edge — the + // app's "you are here / active" focus color, applied sparingly. + missionRailStyle = lipgloss.NewStyle().Foreground(kit.CurrentTheme.Focus) ) diff --git a/internal/app/input/autopilot_panel.go b/internal/app/input/autopilot_panel.go index b7b7cc99..cc678f75 100644 --- a/internal/app/input/autopilot_panel.go +++ b/internal/app/input/autopilot_panel.go @@ -43,8 +43,8 @@ type AutopilotStartMsg struct{ Config setting.AutoPilotSettings } // AutopilotSelector is the /autopilot overlay. type AutopilotSelector struct { - respond MissionResponder // injected; nil disables live mission replies - live func() setting.AutoPilotSettings // injected; returns the live session config + refine MissionRefiner // injected; nil disables live mission refinement + live func() setting.AutoPilotSettings // injected; returns the live session config active bool width int @@ -80,10 +80,10 @@ func NewAutopilotSelector() AutopilotSelector { } } -// SetMissionResponder wires the copilot's LLM reply function for the Mission -// dialog. Called by the app once its provider is available; a nil responder -// leaves the dialog usable for composing but without live replies. -func (p *AutopilotSelector) SetMissionResponder(fn MissionResponder) { p.respond = fn } +// SetMissionRefiner wires the copilot's mission-refining function for the Mission +// dialog. Called by the app once its provider is available; a nil refiner leaves +// the dialog usable for composing but without live refinement. +func (p *AutopilotSelector) SetMissionRefiner(fn MissionRefiner) { p.refine = fn } // SetConfigSource wires the getter for the live session config. The panel seeds // its working buffer from it on Enter, so what you edit is the running session's @@ -112,6 +112,24 @@ func (p *AutopilotSelector) Enter(width, height int) { // IsActive implements overlayPanel. func (p *AutopilotSelector) IsActive() bool { return p.active } +// HandlePaste implements pasteHandler: bracketed paste arrives as tea.PasteMsg +// (not a KeyMsg), so the app routes it here. It inserts the pasted text at the +// cursor of whichever editor is open — the Mission composer or the System Prompt +// editor. Views without a text field (menu, export/import) drop the paste. +func (p *AutopilotSelector) HandlePaste(content string) tea.Cmd { + if !p.active || content == "" { + return nil + } + content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content) + switch p.view { + case apMission: + p.mission.input.InsertString(content) + case apSystemPrompt: + p.prompt.InsertString(content) + } + return nil +} + // Dirty reports unsaved edits (used by the header tag). func (p *AutopilotSelector) Dirty() bool { return !p.snap.Equal(p.baseline) } diff --git a/internal/app/model_lifecycle.go b/internal/app/model_lifecycle.go index 4d5cf478..4260a293 100644 --- a/internal/app/model_lifecycle.go +++ b/internal/app/model_lifecycle.go @@ -37,7 +37,7 @@ func newModel(opts setting.RunOptions) (*model, error) { m.applyPersonaAgents() m.wireReminderProviders() m.InitTaskStorage() - m.userInput.Autopilot.SetMissionResponder(m.missionReply) + m.userInput.Autopilot.SetMissionRefiner(m.missionRefine) m.userInput.Autopilot.SetConfigSource(func() setting.AutoPilotSettings { return m.env.AutoPilot }) if err := m.applyRunOptions(opts); err != nil { return nil, err diff --git a/internal/app/update.go b/internal/app/update.go index 3b64c737..ca8d9606 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -110,7 +110,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The /autopilot Mission dialog runs its own spinner while awaiting a // reply. Ticks carry a per-spinner id, so a foreign tick returns a nil // cmd here and falls through to the conversation spinner below. - if m.userInput.Autopilot.Thinking() { + if m.userInput.Autopilot.Refining() { if cmd := m.userInput.Autopilot.UpdateSpinner(msg); cmd != nil { return m, cmd } @@ -141,10 +141,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // the textarea (unlike AgentToggleMsg/SkillCycleMsg below, which do // need a reaction). return m, nil - case input.MissionReplyMsg: - // The /autopilot Mission dialog's copilot reply arrived; hand it to the - // panel to append (or surface an error under the composer). - m.userInput.Autopilot.DeliverMissionReply(msg.Text, msg.Err) + case input.MissionRefinedMsg: + // The refined mission arrived; hand it to the panel to replace the draft + // (or surface an error under the composer). + m.userInput.Autopilot.DeliverRefinedMission(msg.Mission, msg.Err) return m, nil case autopilotDecisionMsg: // The TurnEnd steer's continue/stop verdict came back.