Skip to content
Draft
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
5 changes: 3 additions & 2 deletions .san/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ git commit -s -m "chore: bump version to <new_version>"
**If the canonical repo is `upstream` and its `main` is protected**, pushing
directly will fail. Use this PR-based path instead:

a. Push the commit to the fork (`origin`):
a. Force-push the commit to the fork (`origin`), since the fork's `main`
may still carry the previous release's squash-merged PR commit:
```bash
git push origin main
git push --force-with-lease origin main
```

b. Create a PR from fork `main` to upstream `main`:
Expand Down
102 changes: 102 additions & 0 deletions internal/app/desktop/desktop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Package desktop implements San's full-screen surface: an alt-screen reader that
// renders the same conversation transcript and input as the inline view, but owns
// the whole screen so the entire history scrolls within the managed frame — where
// the inline view instead commits finished output to the terminal's native
// scrollback and can no longer touch it.
//
// It is deliberately chrome-light: one scrollable content viewport above the
// footer (the app's input bar). All conversation/markdown rendering stays in the
// app; the desktop just hands its Pane a width and scrolls the result.
package desktop

import (
"strings"
"time"

"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)

// Pane is the content the app supplies each frame — the conversation transcript,
// rendered at the surface width. Sig is a cheap change signature that gates the
// expensive rebuild.
type Pane struct {
ID string
Sig string
Render func(w, h int) string
}

// TickMsg drives the reader's repaint heartbeat while the surface is active. The
// root model reschedules it (see Tick) until it leaves the desktop.
type TickMsg struct{}

const tickInterval = 80 * time.Millisecond

// Tick schedules the next repaint.
func Tick() tea.Cmd {
return tea.Tick(tickInterval, func(time.Time) tea.Msg { return TickMsg{} })
}

// Manager owns the alt-screen reader: a single scrollable content viewport plus
// the footer. It is a value held by the root model.
type Manager struct {
w, h int
vp viewport.Model
footer string
sig string
cw, ch int
ready bool
}

func New() Manager { return Manager{vp: viewport.New()} }

func (mgr *Manager) Resize(w, h int) { mgr.w, mgr.h = w, h }

// SetFooter sets the bottom strip (the input bar), drawn below the transcript.
func (mgr *Manager) SetFooter(s string) { mgr.footer = s }

// SetContent syncs the scrollable transcript, rebuilding only when the signature
// or content size changes — so a markdown re-render happens on real change, not
// every frame. The reader stays pinned to the bottom when it was already there,
// so streaming output keeps the latest line in view.
func (mgr *Manager) SetContent(p Pane) {
h := max(mgr.h-footerLines(mgr.footer), 1)
if mgr.ready && mgr.sig == p.Sig && mgr.cw == mgr.w && mgr.ch == h {
return
}
atBottom := !mgr.ready || mgr.vp.AtBottom()
mgr.sig, mgr.cw, mgr.ch, mgr.ready = p.Sig, mgr.w, h, true
mgr.vp.SetWidth(mgr.w)
mgr.vp.SetHeight(h)
mgr.vp.SetContent(p.Render(mgr.w, h))
if atBottom {
mgr.vp.GotoBottom()
}
}

// Render composes the scrollable transcript above the footer — the full-screen
// counterpart of the inline view.
func (mgr *Manager) Render() string {
if mgr.w < 1 || mgr.h < 1 {
return ""
}
if footerLines(mgr.footer) > 0 {
return lipgloss.JoinVertical(lipgloss.Left, mgr.vp.View(), mgr.footer)
}
return mgr.vp.View()
}

// Scroll forwards a paging key or mouse-wheel event to the transcript viewport.
func (mgr *Manager) Scroll(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
mgr.vp, cmd = mgr.vp.Update(msg)
return cmd
}

func footerLines(s string) int {
if s == "" {
return 0
}
return strings.Count(s, "\n") + 1
}
40 changes: 40 additions & 0 deletions internal/app/desktop/desktop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package desktop

import (
"strings"
"testing"
)

func TestRenderPlacesTranscriptAboveFooter(t *testing.T) {
mgr := New()
mgr.Resize(80, 20)
mgr.SetFooter("────\n> type here")
mgr.SetContent(Pane{ID: "c", Sig: "1", Render: func(w, h int) string {
return strings.Repeat("transcript line\n", 50)
}})

out := mgr.Render()
if !strings.Contains(out, "> type here") {
t.Fatalf("render missing footer:\n%s", out)
}
if lines := strings.Count(out, "\n") + 1; lines != 20 {
t.Fatalf("render height = %d lines, want the full 20", lines)
}
}

func TestSetContentRebuildsOnlyOnChange(t *testing.T) {
mgr := New()
mgr.Resize(80, 20)
calls := 0
render := func(w, h int) string { calls++; return "x" }

mgr.SetContent(Pane{ID: "c", Sig: "1", Render: render})
mgr.SetContent(Pane{ID: "c", Sig: "1", Render: render}) // same sig + size → cached
if calls != 1 {
t.Fatalf("render called %d times, want 1 (cached on signature)", calls)
}
mgr.SetContent(Pane{ID: "c", Sig: "2", Render: render}) // new sig → rebuild
if calls != 2 {
t.Fatalf("render called %d times, want 2 after the signature changed", calls)
}
}
135 changes: 135 additions & 0 deletions internal/app/desktop_surface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Desktop surface wiring: the seam between the root model and the alt-screen
// reader (internal/app/desktop). The desktop renders the same conversation
// transcript and input as the inline view — reusing renderTranscriptAt and the
// inline footer — but full-screen and scrollable, so the whole history stays in
// the managed frame. This file owns the Surface enum, the toggle, the per-frame
// content snapshot, and key/mouse routing while the desktop is active.
package app

import (
"fmt"
"strings"

tea "charm.land/bubbletea/v2"

"github.com/genai-io/san/internal/app/conv"
"github.com/genai-io/san/internal/app/desktop"
)

// Surface selects which presentation the root model renders.
type Surface int

const (
// SurfaceInline is the default: the live tail renders inline and finished
// output is committed to the terminal's native scrollback.
SurfaceInline Surface = iota
// SurfaceDesktop is the opt-in full-screen alt-screen reader: the same view,
// but San owns the whole screen and the full history scrolls within it.
SurfaceDesktop
)

// enterDesktop switches to the desktop, builds its first frame, and starts the
// repaint heartbeat — but only one tick chain, so repeated toggles don't stack
// heartbeats (the chain dies in Update when the surface goes inline).
func (m *model) enterDesktop() tea.Cmd {
m.env.Surface = SurfaceDesktop
m.syncDesktop()
if m.desktopTicking {
return nil
}
m.desktopTicking = true
return desktop.Tick()
}

// desktopFlushMsg is posted by exitDesktop and handled one loop iteration later,
// after the inline View has repainted (and the renderer has left the alt-screen)
// — so the backlog commit's tea.Println lands in the inline buffer, not on top
// of the alt-screen.
type desktopFlushMsg struct{}

// exitDesktop returns to the inline surface and schedules the backlog flush.
func (m *model) exitDesktop() tea.Cmd {
m.env.Surface = SurfaceInline
return func() tea.Msg { return desktopFlushMsg{} }
}

// syncDesktop reconciles the desktop's size, transcript, and input bar with the
// live model. Cheap to call every frame: the transcript is cached behind a
// signature inside the desktop, so the heavy render runs only on real change.
func (m *model) syncDesktop() {
m.desktop.Resize(m.env.Width, m.env.Height)
m.desktop.SetFooter(m.desktopFooter())
m.desktop.SetContent(desktop.Pane{
ID: "conversation",
Sig: m.desktopSig(),
Render: func(w, _ int) string { return m.renderTranscriptAt(w) },
})
}

// desktopFooter is the same footer the inline view draws (separator, input,
// suggestions, status), so the two surfaces read identically.
func (m *model) desktopFooter() string {
separator := conv.SeparatorStyle.Render(strings.Repeat("─", m.env.Width))
return m.renderFooter(separator)
}

func (m *model) desktopSig() string {
lastContent, lastThinking := 0, 0
if n := len(m.conv.Messages); n > 0 {
lastContent = len(m.conv.Messages[n-1].Content)
lastThinking = len(m.conv.Messages[n-1].Thinking)
}
return fmt.Sprintf("%d|%d|%d|%d",
len(m.conv.Messages), m.conv.CommittedCount, lastContent, lastThinking)
}

// renderTranscriptAt renders the whole conversation at the given width, reusing
// the inline renderers so the desktop transcript reads identically. The split
// mirrors the inline view exactly: finished history is rendered statically (as
// inline commits it to native scrollback), while the active tail goes through
// RenderActiveContent + renderChatSection — the same path that drives the live
// spinner and the running-tool animation above the inline input. It uses a
// surface-sized markdown renderer, off the live view's MDRenderer mutex.
func (m *model) renderTranscriptAt(width int) string {
if m.desktopMD == nil || m.desktopMDWidth != width {
m.desktopMD = conv.NewMDRenderer(width)
m.desktopMDWidth = width
}
params := m.messageRenderParams()
params.Width = width
params.MDRenderer = m.desktopMD

history := conv.RenderMessageRange(params, 0, params.CommittedCount, false)
live := m.renderChatSection(conv.RenderActiveContent(params), m.renderTrackerList())

parts := make([]string, 0, 2)
if strings.TrimSpace(history) != "" {
parts = append(parts, history)
}
if strings.TrimSpace(live) != "" {
parts = append(parts, live)
}
return strings.Join(parts, "\n")
}

// ── key routing while the desktop is active ──────────────────────────────────

// handleDesktopKey routes keys while the desktop surface is active: exit,
// scrolling the transcript, and typing/submitting into the input bar (so chatting
// keeps working). It returns (cmd, true) when it consumes the key, or (nil,
// false) for ctrl+c so the global handler still quits/cancels.
func (m *model) handleDesktopKey(msg tea.KeyMsg) (tea.Cmd, bool) {
switch msg.String() {
case "ctrl+g", "esc":
return m.exitDesktop(), true
case "ctrl+c":
return nil, false // let the global handler quit/cancel
case "pgup", "pgdown", "ctrl+u", "ctrl+d", "home", "end":
return m.desktop.Scroll(msg), true
case "enter":
return m.handleSubmit(), true
}
// Everything else types into the input bar, so chatting keeps working.
cmd, _ := m.userInput.HandleTextareaUpdate(msg)
return cmd, true
}
3 changes: 3 additions & 0 deletions internal/app/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type env struct {
Height int
Ready bool
InitialPrompt string
// Surface selects the active presentation: the default inline scrollback
// view, or the opt-in full-screen desktop. See desktop_surface.go.
Surface Surface

// ── Provider (mutable — changes via SwitchProvider) ─────────
LLMProvider llm.Provider
Expand Down
26 changes: 18 additions & 8 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
tea "charm.land/bubbletea/v2"

"github.com/genai-io/san/internal/app/conv"
"github.com/genai-io/san/internal/app/desktop"
"github.com/genai-io/san/internal/app/hub"
"github.com/genai-io/san/internal/app/input"
"github.com/genai-io/san/internal/app/trigger"
Expand All @@ -38,14 +39,23 @@ const defaultWidth = 80

type model struct {
// ── Sub-models (one per event source / concern) ─────────────
userInput input.Model // Source 1: user keyboard input
agentEventHub *hub.Hub // Source 2: inter-agent event routing (pure pub/sub)
mainEvents chan hub.Event // hub-side delivery chan; awaitMainEvent reads it
pendingMainEvents []hub.Event // events that arrived mid-stream, drained at OnTurnEnd
systemInput trigger.Model // Source 3: system events (cron/hooks/watcher)
conv conv.Model // Agent Outbox: conversation + output rendering
env env // Shared app state: provider, session, permission, plan, config
services services // Domain service singletons, injected at construction
userInput input.Model // Source 1: user keyboard input
agentEventHub *hub.Hub // Source 2: inter-agent event routing (pure pub/sub)
mainEvents chan hub.Event // hub-side delivery chan; awaitMainEvent reads it
pendingMainEvents []hub.Event // events that arrived mid-stream, drained at OnTurnEnd
systemInput trigger.Model // Source 3: system events (cron/hooks/watcher)
conv conv.Model // Agent Outbox: conversation + output rendering
desktop desktop.Manager // Alt-screen "desktop" surface (opt-in via ctrl+g)
env env // Shared app state: provider, session, permission, plan, config
services services // Domain service singletons, injected at construction

// Desktop-surface scratch state (see desktop_surface.go): a single-chain
// guard for the animation tick, plus a width-keyed cache of the markdown
// renderer so streaming into the desktop's conversation window doesn't
// rebuild glamour every frame.
desktopTicking bool
desktopMD *conv.MDRenderer
desktopMDWidth int

// welcomePending marks the startup splash as not yet frozen into scrollback.
// While set, the splash renders live above the input (visible from launch
Expand Down
2 changes: 2 additions & 0 deletions internal/app/model_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"sync/atomic"

"github.com/genai-io/san/internal/app/conv"
"github.com/genai-io/san/internal/app/desktop"
"github.com/genai-io/san/internal/app/hub"
"github.com/genai-io/san/internal/app/input"
"github.com/genai-io/san/internal/app/trigger"
Expand Down Expand Up @@ -65,6 +66,7 @@ func newBaseModel() model {
UpdateDisabled: svc.Setting.UpdateDisabledToolsAt,
}),
conv: conv.NewModel(defaultWidth),
desktop: desktop.New(),
agentEventHub: hub.New(),
mainEvents: make(chan hub.Event, 64),
systemInput: trigger.New(),
Expand Down
13 changes: 13 additions & 0 deletions internal/app/model_scrollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ type flushResultMsg struct {
// kicks off their background render. Returns nil when a render is already in
// flight or nothing new is ready to flush.
func (m *model) FlushStreamingBlocks() []tea.Cmd {
// While the desktop owns the screen (alt-screen), tea.Println would corrupt
// it — insertAbove assumes the inline buffer. Hold all commits; the desktop
// renders the full conversation from m.conv.Messages anyway, and exitDesktop
// flushes the backlog once the inline view is repainted.
if m.env.Surface == SurfaceDesktop {
return nil
}
if m.flush.rendering {
return nil // a block is already rendering off-thread; wait for it to land
}
Expand Down Expand Up @@ -235,6 +242,12 @@ func (m *model) commitAllMessages() []tea.Cmd {
}

func (m *model) renderAndCommit(checkReady bool) []tea.Cmd {
// Suppress scrollback writes while the desktop is active (see
// FlushStreamingBlocks). CommittedCount is left untouched so the whole
// backlog flushes intact when exitDesktop returns to the inline surface.
if m.env.Surface == SurfaceDesktop {
return nil
}
var parts []string
lastIdx := len(m.conv.Messages) - 1
params := m.messageRenderParams()
Expand Down
Loading