Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 18 additions & 21 deletions internal/app/autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 agenta 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
Expand Down
226 changes: 137 additions & 89 deletions internal/app/input/autopilot_mission.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 {
Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -171,43 +192,70 @@ 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))
}
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)
)
Loading