Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ type piRPCWorker struct {
| `/api/sessions` | GET | `handleApiSessions` | JSON list of session summaries |
| `/api/chat` | POST | `handleChat` | Send chat message (multipart) |
| `/api/chat/cancel` | POST | `handleCancelChat` | Abort running chat worker |
| `/api/compact` | POST | `handleCompact` | Compact the current session through its RPC worker |
| `/api/set-model` | POST | `handleSetModel` | Change model for session |
| `/api/set-thinking-level` | POST | `handleSetThinkingLevel` | Change thinking level |
| `/api/models` | GET | `handleAvailableModels` | List available AI models |
Expand Down
25 changes: 25 additions & 0 deletions docs/architecture/data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,31 @@ Browser POST /api/chat?id=<id>
└──▶ Return {"ok": true, "status": "accepted"}
```

## Data Flow: Compact Current Session

```
Browser POST /api/compact?id=<id>
server.handleCompact
├──▶ sessions.ResolveByID → Session + Path
├──▶ workers.Manager.Compact(ctx, sessionID, sessionPath, instructions)
│ ├──▶ Reuse or create the session's ChatWorker
│ └──▶ worker.Compact
│ ├──▶ Reject while the worker is running
│ ├──▶ Write {"type":"compact"} JSONL to pi stdin
│ └──▶ Await the correlated RPC response
├──▶ Broadcast "reload" to the session SSE clients
└──▶ Return {"ok": true, "status": "compacted"}
```

The context popover button sends an empty instruction string so Pi or an installed
compaction extension can apply its defaults. An attachment-free composer message
matching `/compact [instructions]` is intercepted and sent to this endpoint; it is
never forwarded as a normal prompt. With pi-vcc installed, `keep:N` is supported by
passing it as `customInstructions`.

## Data Flow: Rename Session

```
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The message pane is rendered by Svelte components (no string-building renderer):
- `data/` — payload decoding + the reactive `SessionDataModel` (`session-data.svelte.js`, the single source of truth: entries/lookups/tree/active-path/view-state, `reconcile()`)
- `tree/`, `render/`, `navigation/` — **pure** tree/format/markdown/navigation helpers consumed by the Svelte components (and the export). The message renderer is now `<SessionEntry>`/`<ToolCall>`; `render/` keeps `session-format`, `markdown`, `entry-format`, `session-entry-actions` (download/share/copy)
- `session-globals.js`, `session-content-runtime.js`, `lazy-highlight.js` — the relocated live glue (see above)
- `chat/` — **pure/shared helpers**: `chat-api` + `git-api` (fetch wrappers), `chat-selectors` (pure model/thinking helpers), `done-notifier` (shared notification/sound/push util, also used by the settings page). Live composer DOM helpers live under `web/src/components/session/chat/`, wired together by `chat-composer-runtime.js` (`runChatComposer`, mounted by `<ChatComposer>`).
- `chat/` — **pure/shared helpers**: `chat-api` + `git-api` (fetch wrappers), `chat-selectors` (pure model/thinking helpers), `done-notifier` (shared notification/sound/push util, also used by the settings page). Live composer DOM helpers live under `web/src/components/session/chat/`, wired together by `chat-composer-runtime.js` (`runChatComposer`, mounted by `<ChatComposer>`). The context popover can manually compact the active session, and an attachment-free `/compact [instructions]` submission is intercepted locally and routed to the same `/api/compact` endpoint instead of becoming a prompt.
- `live/` — live-only helpers used by `<LiveReload>`: `live-connection.js` (SSE connection/reconnect lifecycle), `live-events.js` (SSE/reload primitives), `live-scroll.js` (low-level scroll primitives), `live-follow.js` (`createFollowScrollController` — follow-mode decision state + follow button), `live-stats.js` (header stats), and `chat-preview.js` (streaming-preview helper, also used by `<BtwPopup>`)
- `ui/` — sidebar/search/toggle/session-ui-runner helpers used by `setupSessionUi` and `RightSidebar`
- `artifacts/`, `annotations/` — pure registries/filters/ranges + the fetch API wrappers; the panels themselves are `ArtifactPanel.svelte`/`AnnotationLayer.svelte`
Expand Down
8 changes: 8 additions & 0 deletions internal/rpc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ func BuildAbortCommand(id string) map[string]any {
return map[string]any{"id": id, "type": "abort"}
}

func BuildCompactCommand(id, customInstructions string) map[string]any {
cmd := map[string]any{"id": id, "type": "compact"}
if customInstructions != "" {
cmd["customInstructions"] = customInstructions
}
return cmd
}

func BuildSetThinkingLevelCommand(id, level string) map[string]any {
return map[string]any{"id": id, "type": "set_thinking_level", "level": level}
}
Expand Down
11 changes: 11 additions & 0 deletions internal/rpc/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ func TestBuildGetCommandsCommand(t *testing.T) {
}
}

func TestBuildCompactCommand(t *testing.T) {
cmd := BuildCompactCommand("req-8", "keep:3")
if cmd["id"] != "req-8" || cmd["type"] != "compact" || cmd["customInstructions"] != "keep:3" {
t.Fatalf("cmd = %#v", cmd)
}
cmd = BuildCompactCommand("req-9", "")
if _, ok := cmd["customInstructions"]; ok {
t.Fatalf("customInstructions present for default compact command")
}
}

func TestWriteRPCCommandWritesJSONLine(t *testing.T) {
var buf bytes.Buffer
if err := WriteCommand(&buf, map[string]any{"type": "get_state"}); err != nil {
Expand Down
19 changes: 19 additions & 0 deletions internal/rpc/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,25 @@ func (w *piRPCWorker) Prompt(ctx context.Context, chat chat.Request) error {
return nil
}

func (w *piRPCWorker) Compact(ctx context.Context, customInstructions string) error {
w.touch()
w.mu.Lock()
if w.status.State == workers.WorkerStateRunning || w.hasRecentStreamActivityLocked(time.Now()) {
w.mu.Unlock()
return workers.ErrWorkerBusy
}
w.status.State = workers.WorkerStateRunning
w.status.Error = ""
w.mu.Unlock()

err := w.sendAndAwait(ctx, BuildCompactCommand(w.nextID(), customInstructions))
w.mu.Lock()
w.status.State = workers.WorkerStateIdle
w.status.Error = ""
w.mu.Unlock()
return err
}

func (w *piRPCWorker) SetModel(ctx context.Context, provider, modelID string) error {
w.touch()
id := w.nextID()
Expand Down
34 changes: 34 additions & 0 deletions internal/rpc/worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ func waitForPending(t *testing.T, w *piRPCWorker, id string) {
t.Fatalf("pending request %q never registered", id)
}

func TestCompactWritesRPCCommandAndReturnsIdle(t *testing.T) {
var buf bytes.Buffer
w := &piRPCWorker{
stdin: nopWriteCloser{&buf},
status: workers.WorkerStatus{State: workers.WorkerStateIdle},
pending: make(map[string]chan response),
}
done := make(chan error, 1)
go func() { done <- w.Compact(context.Background(), "keep:3") }()

waitForPending(t, w, "req-1")
w.handleRPCLine(`{"type":"response","id":"req-1","command":"compact","success":true,"data":{}}`)

if err := <-done; err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(buf.String()); got != `{"customInstructions":"keep:3","id":"req-1","type":"compact"}` {
t.Fatalf("command = %s", got)
}
if got := w.Status(); got.State != workers.WorkerStateIdle {
t.Fatalf("status = %q, want idle", got.State)
}
}

func TestCompactRejectsRunningWorker(t *testing.T) {
w := &piRPCWorker{
status: workers.WorkerStatus{State: workers.WorkerStateRunning},
pending: make(map[string]chan response),
}
if err := w.Compact(context.Background(), ""); err != workers.ErrWorkerBusy {
t.Fatalf("error = %v, want ErrWorkerBusy", err)
}
}

func TestStatusReportsRunningDuringRecentStreamActivity(t *testing.T) {
w := &piRPCWorker{
status: workers.WorkerStatus{State: workers.WorkerStateIdle},
Expand Down
42 changes: 42 additions & 0 deletions internal/server/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"

"pi-web/internal/chat"
Expand All @@ -17,6 +18,7 @@ import (

type ChatSender interface {
Send(ctx context.Context, sessionID, sessionPath string, chat chat.Request) error
Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error
SetModel(ctx context.Context, sessionID, sessionPath, provider, modelID string) error
SetThinkingLevel(ctx context.Context, sessionID, sessionPath, level string) error
Abort(ctx context.Context, sessionID string) error
Expand Down Expand Up @@ -134,6 +136,46 @@ func (s *Server) handleCancelChat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"ok": true, "status": "cancelled"})
}

func (s *Server) handleCompact(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
resolved, err := sessions.ResolveByID(s.sessionsDir, r.URL.Query().Get("id"))
if resolveOrWriteError(w, err) {
return
}
if !resolved.Session.ChatAvailable {
writeJSONError(w, http.StatusConflict, resolved.Session.ChatDisabledReason)
return
}
if s.chatSender == nil {
writeJSONError(w, http.StatusServiceUnavailable, "chat unavailable")
return
}
var body struct {
CustomInstructions string `json:"customInstructions"`
}
if !decodeJSONBody(w, r, &body) {
return
}
if err := s.chatSender.Compact(
r.Context(),
resolved.Session.ID,
resolved.Path,
strings.TrimSpace(body.CustomInstructions),
); err != nil {
if errors.Is(err, workers.ErrWorkerBusy) {
writeJSONError(w, http.StatusConflict, err.Error())
return
}
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcast(resolved.Session.ID, "reload")
writeJSON(w, 0, map[string]any{"ok": true, "status": "compacted"})
}

func (s *Server) handleWorkerStatus(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("id")

Expand Down
58 changes: 58 additions & 0 deletions internal/server/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type fakeSender struct {
setModelID string
setThinkingSessionID string
setThinkingLevel string
compactSessionID string
compactSessionPath string
compactInstructions string
compactErr error
getCommandsCalls int
}

Expand Down Expand Up @@ -79,6 +83,15 @@ func (f *fakeSender) SetThinkingLevel(ctx context.Context, sessionID, sessionPat
return nil
}

func (f *fakeSender) Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error {
f.mu.Lock()
f.compactSessionID = sessionID
f.compactSessionPath = sessionPath
f.compactInstructions = customInstructions
f.mu.Unlock()
return f.compactErr
}

func (f *fakeSender) Abort(ctx context.Context, sessionID string) error {
return nil
}
Expand Down Expand Up @@ -155,6 +168,12 @@ func (f *fakeSender) thinkingSessionID() string {
return f.setThinkingSessionID
}

func (f *fakeSender) compactInfo() (sessionID, sessionPath, instructions string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.compactSessionID, f.compactSessionPath, f.compactInstructions
}

func TestHandleChatQueuesResolvedSession(t *testing.T) {
root := t.TempDir()
wantPath := writeSessionFile(t, root, "--tmp--project--", "session.jsonl")
Expand Down Expand Up @@ -230,6 +249,45 @@ func TestHandleChatRejectsBrokenSession(t *testing.T) {
}
}

func TestHandleCompactUsesResolvedSession(t *testing.T) {
root := t.TempDir()
wantPath := writeSessionFile(t, root, "--tmp--project--", "session.jsonl")
fake := &fakeSender{}
s := &Server{sessionsDir: root, chatSender: fake}
req := httptest.NewRequest(
http.MethodPost,
"/api/compact?id=session.jsonl",
strings.NewReader(`{"customInstructions":" keep:3 "}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

s.handleCompact(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
gotID, gotPath, gotInstructions := fake.compactInfo()
if gotID != "session.jsonl" || gotPath != wantPath || gotInstructions != "keep:3" {
t.Fatalf("compact id=%q path=%q instructions=%q, want path %q", gotID, gotPath, gotInstructions, wantPath)
}
}

func TestHandleCompactRejectsBusyWorker(t *testing.T) {
root := t.TempDir()
writeSessionFile(t, root, "--tmp--project--", "session.jsonl")
s := &Server{sessionsDir: root, chatSender: &fakeSender{compactErr: workers.ErrWorkerBusy}}
req := httptest.NewRequest(http.MethodPost, "/api/compact?id=session.jsonl", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

s.handleCompact(w, req)

if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", w.Code, w.Body.String())
}
}

func TestHandleWorkerStatusDefaultsIdle(t *testing.T) {
s := &Server{sessionsDir: t.TempDir(), chatSender: &fakeSender{}, now: time.Now}
req := httptest.NewRequest(http.MethodGet, "/api/worker-status?id=session.jsonl", nil)
Expand Down
1 change: 1 addition & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ func (s *Server) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/sessions", s.auth.Wrap(s.handleApiSessions))
mux.HandleFunc("/api/chat", s.auth.Wrap(s.handleChat))
mux.HandleFunc("/api/chat/cancel", s.auth.Wrap(s.handleCancelChat))
mux.HandleFunc("/api/compact", s.auth.Wrap(s.handleCompact))
mux.HandleFunc("/api/set-model", s.auth.Wrap(s.handleSetModel))
mux.HandleFunc("/api/set-thinking-level", s.auth.Wrap(s.handleSetThinkingLevel))
mux.HandleFunc("/api/models", s.auth.Wrap(s.handleAvailableModels))
Expand Down
27 changes: 27 additions & 0 deletions internal/ui/embedded/styles/session.css
Original file line number Diff line number Diff line change
Expand Up @@ -6469,6 +6469,33 @@
color: var(--error, #cc6666);
}

.pi-chat-context-popover .pi-context-compact {
width: 100%;
min-height: 30px;
margin-top: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--dim));
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--text);
font-size: 10px;
font-weight: 600;
cursor: pointer;
}

.pi-chat-context-popover .pi-context-compact:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 20%, transparent);
border-color: var(--accent);
}

.pi-chat-context-popover .pi-context-compact:disabled {
opacity: 0.45;
cursor: not-allowed;
}

/* ===================================================================
Cat Gatekeeper — focus/break + bedtime overlay (live app only).
The overlay DOM is created by web/src/session/cat-gatekeeper/.
Expand Down
11 changes: 11 additions & 0 deletions internal/workers/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"pi-web/internal/chat"
)

var ErrWorkerBusy = errors.New("worker is busy")

type State string

const (
Expand Down Expand Up @@ -57,6 +59,7 @@ type inspector interface {

type ChatWorker interface {
Prompt(ctx context.Context, chat chat.Request) error
Compact(ctx context.Context, customInstructions string) error
SetModel(ctx context.Context, provider, modelID string) error
SetThinkingLevel(ctx context.Context, level string) error
Abort(ctx context.Context) error
Expand Down Expand Up @@ -236,6 +239,14 @@ func (m *Manager) SetModel(ctx context.Context, sessionID, sessionPath, provider
return worker.SetModel(ctx, provider, modelID)
}

func (m *Manager) Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error {
worker, err := m.workerFor(sessionID, sessionPath)
if err != nil {
return err
}
return worker.Compact(ctx, customInstructions)
}

func (m *Manager) SetThinkingLevel(ctx context.Context, sessionID, sessionPath, level string) error {
worker, err := m.workerFor(sessionID, sessionPath)
if err != nil {
Expand Down
Loading