diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5f3edca7ebfe..80e976475c15 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -224,6 +224,7 @@ export namespace Config { .optional() .default("ctrl+left") .describe("Cycle to previous child session"), + scratchpad_open: z.string().optional().default("w").describe("Open session scratchpad"), messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"), messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e661471ae022..68190384f673 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -750,6 +750,73 @@ export namespace Server { return c.json(true) }, ) + .get( + "/session/:id/scratchpad", + describeRoute({ + description: "Get session system scratchpad content", + operationId: "session.scratchpad.get", + responses: { + 200: { + description: "System scratch content", + content: { + "application/json": { + schema: resolver(z.object({ scratchpad: z.string() })), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + id: z.string().openapi({ description: "Session ID" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").id + const session = await Session.get(sessionID) + return c.json({ scratchpad: session?.scratchpad || "" }) + }, + ) + .put( + "/session/:id/scratchpad", + describeRoute({ + description: "Update session system scratchpad content", + operationId: "session.scratchpad.update", + responses: { + 200: { + description: "System scratch updated successfully", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + id: z.string().openapi({ description: "Session ID" }), + }), + ), + zValidator( + "json", + z.object({ + scratchpad: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").id + const { scratchpad } = c.req.valid("json") + + const updatedSession = await Session.update(sessionID, (session) => { + session.scratchpad = scratchpad + }) + + return c.json(updatedSession) + }, + ) .get( "/config/providers", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2455962d8d7c..46ac258c02df 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -86,6 +86,7 @@ export namespace Session { diff: z.string().optional(), }) .optional(), + scratchpad: z.string().optional(), }) .openapi({ ref: "Session", diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index 9cc0492cf766..63e9faf679aa 100644 --- a/packages/sdk/go/session.go +++ b/packages/sdk/go/session.go @@ -238,6 +238,30 @@ func (r *SessionService) Unshare(ctx context.Context, id string, opts ...option. return } +// Get session scratchpad content +func (r *SessionService) ScratchpadGet(ctx context.Context, id string, opts ...option.RequestOption) (res *SessionScratchpadGetResponse, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/scratchpad", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Update session system scratch content +func (r *SessionService) ScratchpadUpdate(ctx context.Context, id string, body SessionScratchpadUpdateParams, opts ...option.RequestOption) (res *Session, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/scratchpad", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPut, path, body, &res, opts...) + return +} + type AgentPart struct { ID string `json:"id,required"` MessageID string `json:"messageID,required"` @@ -1282,14 +1306,15 @@ func (r ReasoningPartType) IsKnown() bool { } type Session struct { - ID string `json:"id,required"` - Time SessionTime `json:"time,required"` - Title string `json:"title,required"` - Version string `json:"version,required"` - ParentID string `json:"parentID"` - Revert SessionRevert `json:"revert"` - Share SessionShare `json:"share"` - JSON sessionJSON `json:"-"` + ID string `json:"id,required"` + Time SessionTime `json:"time,required"` + Title string `json:"title,required"` + Version string `json:"version,required"` + Scratchpad string `json:"scratchpad"` + ParentID string `json:"parentID"` + Revert SessionRevert `json:"revert"` + Share SessionShare `json:"share"` + JSON sessionJSON `json:"-"` } // sessionJSON contains the JSON metadata for the struct [Session] @@ -2432,3 +2457,34 @@ type SessionSummarizeParams struct { func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } + +type SessionScratchpadGetResponse struct { + Scratchpad string `json:"scratchpad,required"` + JSON sessionScratchpadGetResponseJSON `json:"-"` +} + +type sessionScratchpadGetResponseJSON struct { + Scratchpad apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r sessionScratchpadGetResponseJSON) RawJSON() string { + return r.raw +} + +func (r *SessionScratchpadGetResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionScratchpadGetResponseJSON) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +type SessionScratchpadUpdateParams struct { + Scratchpad param.Field[string] `json:"scratchpad,required"` +} + +func (r SessionScratchpadUpdateParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index f046daaefbb5..1a01b0731697 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -727,10 +727,23 @@ func (a *App) MarkProjectInitialized(ctx context.Context) error { } func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { + // If there is homescreen system scratch content, transfer it to the new session after creation + session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{}) if err != nil { return nil, err } + // Transfer homescreen system scratch content if present + if a.State.HomescreenScratchpad != "" { + _, err := a.Client.Session.ScratchpadUpdate(ctx, session.ID, opencode.SessionScratchpadUpdateParams{ + Scratchpad: opencode.F(a.State.HomescreenScratchpad), + }) + if err == nil { + session.Scratchpad = a.State.HomescreenScratchpad + a.State.HomescreenScratchpad = "" + a.SaveState() + } + } return session, nil } @@ -746,18 +759,34 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) { } messageID := id.Ascending(id.Message) - message := prompt.ToMessage(messageID, a.Session.ID) + // Create user message (scratchpad content is sent as system message if present) + message := prompt.ToMessage(messageID, a.Session.ID) a.Messages = append(a.Messages, message) + // Prepare system message from scratchpad content if present + var systemMessage *string + scratchpadContent := a.GetSessionScratchpad() + if strings.TrimSpace(scratchpadContent) != "" { + systemPrompt := "The user has shared a scratchpad for this session. If it contains a task list, you MUST use the `todowrite` tool to track its items.\n\nUser's scratchpad content:\n" + scratchpadContent + "\n\n---" + systemMessage = &systemPrompt + } + cmds = append(cmds, func() tea.Msg { - _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ + chatParams := opencode.SessionChatParams{ ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), Agent: opencode.F(a.Agent().Name), MessageID: opencode.F(messageID), Parts: opencode.F(message.ToSessionChatParams()), - }) + } + + // Add system message if scratchpad content exists + if systemMessage != nil { + chatParams.System = opencode.F(*systemMessage) + } + + _, err := a.Client.Session.Chat(ctx, a.Session.ID, chatParams) if err != nil { errormsg := fmt.Sprintf("failed to send message: %v", err) slog.Error(errormsg) @@ -850,6 +879,49 @@ func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) return nil } +func (a *App) GetSessionScratchpad() string { + if a.Session == nil { + return "" + } + return a.Session.Scratchpad +} + + +func (a *App) SaveSessionScratchpad(content string) error { + if a.Session == nil { + return fmt.Errorf("no active session") + } + + ctx := context.Background() + _, err := a.Client.Session.ScratchpadUpdate(ctx, a.Session.ID, opencode.SessionScratchpadUpdateParams{ + Scratchpad: opencode.F(content), + }) + if err != nil { + slog.Error("Failed to save system scratch", "error", err) + return err + } + + // Update local session + a.Session.Scratchpad = content + return nil +} + +func (a *App) LoadSessionScratchpad(ctx context.Context) error { + if a.Session == nil { + return fmt.Errorf("no active session") + } + + response, err := a.Client.Session.ScratchpadGet(ctx, a.Session.ID) + if err != nil { + slog.Error("Failed to load system scratch", "error", err) + return err + } + + // Update local session + a.Session.Scratchpad = response.Scratchpad + return nil +} + func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) { response, err := a.Client.Session.Messages(ctx, sessionId) if err != nil { diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go index cc65eea5eeed..d8bc2e28acab 100644 --- a/packages/tui/internal/app/state.go +++ b/packages/tui/internal/app/state.go @@ -27,16 +27,17 @@ type AgentModel struct { } type State struct { - Theme string `toml:"theme"` - AgentModel map[string]AgentModel `toml:"agent_model"` - Provider string `toml:"provider"` - Model string `toml:"model"` - Agent string `toml:"agent"` - RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` - RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"` - MessageHistory []Prompt `toml:"message_history"` - ShowToolDetails *bool `toml:"show_tool_details"` - ShowThinkingBlocks *bool `toml:"show_thinking_blocks"` + HomescreenScratchpad string `toml:"homescreen_scratchpad"` // persists system scratch when no session is active + Theme string `toml:"theme"` + AgentModel map[string]AgentModel `toml:"agent_model"` + Provider string `toml:"provider"` + Model string `toml:"model"` + Agent string `toml:"agent"` + RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` + RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"` + MessageHistory []Prompt `toml:"message_history"` + ShowToolDetails *bool `toml:"show_tool_details"` + ShowThinkingBlocks *bool `toml:"show_thinking_blocks"` } func NewState() *State { diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index a4a5e4f7f7ca..89956392f250 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -107,7 +107,6 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command { } const ( - SessionChildCycleCommand CommandName = "session_child_cycle" SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse" ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" @@ -122,37 +121,38 @@ const ( SessionNavigationCommand CommandName = "session_navigation" SessionShareCommand CommandName = "session_share" SessionUnshareCommand CommandName = "session_unshare" - SessionInterruptCommand CommandName = "session_interrupt" - SessionCompactCommand CommandName = "session_compact" - SessionExportCommand CommandName = "session_export" - ToolDetailsCommand CommandName = "tool_details" - ThinkingBlocksCommand CommandName = "thinking_blocks" - ModelListCommand CommandName = "model_list" - AgentListCommand CommandName = "agent_list" - ModelCycleRecentCommand CommandName = "model_cycle_recent" - ThemeListCommand CommandName = "theme_list" - FileListCommand CommandName = "file_list" - FileCloseCommand CommandName = "file_close" - FileSearchCommand CommandName = "file_search" - FileDiffToggleCommand CommandName = "file_diff_toggle" - ProjectInitCommand CommandName = "project_init" - InputClearCommand CommandName = "input_clear" - InputPasteCommand CommandName = "input_paste" - InputSubmitCommand CommandName = "input_submit" - InputNewlineCommand CommandName = "input_newline" - MessagesPageUpCommand CommandName = "messages_page_up" - MessagesPageDownCommand CommandName = "messages_page_down" - MessagesHalfPageUpCommand CommandName = "messages_half_page_up" - MessagesHalfPageDownCommand CommandName = "messages_half_page_down" - MessagesPreviousCommand CommandName = "messages_previous" - MessagesNextCommand CommandName = "messages_next" - MessagesFirstCommand CommandName = "messages_first" - MessagesLastCommand CommandName = "messages_last" - MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" - MessagesCopyCommand CommandName = "messages_copy" - MessagesUndoCommand CommandName = "messages_undo" - MessagesRedoCommand CommandName = "messages_redo" - AppExitCommand CommandName = "app_exit" + SessionInterruptCommand CommandName = "session_interrupt" + SessionCompactCommand CommandName = "session_compact" + SessionExportCommand CommandName = "session_export" + ToolDetailsCommand CommandName = "tool_details" + ThinkingBlocksCommand CommandName = "thinking_blocks" + ModelListCommand CommandName = "model_list" + AgentListCommand CommandName = "agent_list" + ModelCycleRecentCommand CommandName = "model_cycle_recent" + ThemeListCommand CommandName = "theme_list" + FileListCommand CommandName = "file_list" + FileCloseCommand CommandName = "file_close" + FileSearchCommand CommandName = "file_search" + FileDiffToggleCommand CommandName = "file_diff_toggle" + ProjectInitCommand CommandName = "project_init" + InputClearCommand CommandName = "input_clear" + InputPasteCommand CommandName = "input_paste" + InputSubmitCommand CommandName = "input_submit" + InputNewlineCommand CommandName = "input_newline" + MessagesPageUpCommand CommandName = "messages_page_up" + MessagesPageDownCommand CommandName = "messages_page_down" + MessagesHalfPageUpCommand CommandName = "messages_half_page_up" + MessagesHalfPageDownCommand CommandName = "messages_half_page_down" + MessagesPreviousCommand CommandName = "messages_previous" + MessagesNextCommand CommandName = "messages_next" + MessagesFirstCommand CommandName = "messages_first" + MessagesLastCommand CommandName = "messages_last" + MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" + MessagesCopyCommand CommandName = "messages_copy" + MessagesUndoCommand CommandName = "messages_undo" + MessagesRedoCommand CommandName = "messages_redo" + ScratchpadOpenCommand CommandName = "scratchpad_open" + AppExitCommand CommandName = "app_exit" ) func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { @@ -309,6 +309,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("i"), Trigger: []string{"init"}, }, + { + Name: ScratchpadOpenCommand, + Description: "open session system scratch pad", + Keybindings: parseBindings("w"), + Trigger: []string{"scratchpad", "notes", "scratch", "pad"}, + }, { Name: InputClearCommand, Description: "clear input", diff --git a/packages/tui/internal/components/dialog/scratchpad.go b/packages/tui/internal/components/dialog/scratchpad.go new file mode 100644 index 000000000000..892befbbb83b --- /dev/null +++ b/packages/tui/internal/components/dialog/scratchpad.go @@ -0,0 +1,147 @@ +package dialog + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/clipboard" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/components/textarea" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type scratchpadDialog struct { + width int + height int + modal *modal.Modal + textarea textarea.Model + app *app.App +} + +// ScratchpadUpdatedMsg is sent when system scratch content is updated +type ScratchpadUpdatedMsg struct { + Content string +} + +// ScratchpadDialog interface for the system scratch modal +type ScratchpadDialog interface { + layout.Modal + GetContent() string + SetContent(content string) +} + +func (n *scratchpadDialog) Init() tea.Cmd { + return n.textarea.Focus() +} + +func (n *scratchpadDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + n.width = msg.Width + n.height = msg.Height + // Update textarea width to fit modal + n.textarea.SetWidth(layout.Current.Container.Width - 20) + case tea.KeyMsg: + switch msg.String() { + case "esc": + // Save content before closing + content := strings.TrimSpace(n.textarea.Value()) + return n, tea.Sequence( + util.CmdHandler(modal.CloseModalMsg{}), + util.CmdHandler(ScratchpadUpdatedMsg{Content: content}), + ) + case "ctrl+v", "super+v": + // Handle paste directly using clipboard + textBytes := clipboard.Read(clipboard.FmtText) + if textBytes != nil { + text := string(textBytes) + n.textarea.InsertRunesFromUserInput([]rune(text)) + } + return n, nil + } + case tea.PasteMsg: + // Forward paste events to textarea for paste functionality + var cmd tea.Cmd + n.textarea, cmd = n.textarea.Update(msg) + return n, cmd + case tea.ClipboardMsg: + // Forward clipboard events to textarea for paste functionality + var cmd tea.Cmd + n.textarea, cmd = n.textarea.Update(msg) + return n, cmd + } + + var cmd tea.Cmd + n.textarea, cmd = n.textarea.Update(msg) + return n, cmd +} + +func (n *scratchpadDialog) Render(background string) string { + view := n.textarea.View() + helpText := styles.NewStyle(). + Foreground(theme.CurrentTheme().TextMuted()). + Render("Press Esc to close and save") + + content := strings.Join([]string{view, "", helpText}, "\n") + return n.modal.Render(content, background) +} + +func (n *scratchpadDialog) Close() tea.Cmd { + // Save content when closing + content := strings.TrimSpace(n.textarea.Value()) + return util.CmdHandler(ScratchpadUpdatedMsg{Content: content}) +} + +func (n *scratchpadDialog) GetContent() string { + return n.textarea.Value() +} + +func (n *scratchpadDialog) SetContent(content string) { + n.textarea.SetValue(content) +} + +// NewScratchpadDialog creates a new system scratch modal dialog +func NewScratchpadDialog(app *app.App) ScratchpadDialog { + t := theme.CurrentTheme() + bgColor := t.BackgroundPanel() + textColor := t.Text() + textMutedColor := t.TextMuted() + + ta := textarea.New() + ta.SetWidth(layout.Current.Container.Width - 20) + ta.SetHeight(12) + ta.Focus() + ta.CharLimit = 5000 + ta.Placeholder = "Your session scratchpad...\n\nWrite anything here: todos, notes, ideas, system prompt extension etc. This scratchpad is saved with the session and is shared with the agent." + ta.Prompt = "" // Remove the prompt border + + // Style the textarea + ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() + ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() + ta.Styles.Focused.Base = styles.NewStyle(). + Foreground(textColor). + Background(bgColor). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + Lipgloss() + ta.Styles.Blurred.Base = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + Lipgloss() + + return &scratchpadDialog{ + textarea: ta, + modal: modal.New(modal.WithTitle("Scratchpad"), modal.WithMaxWidth(90)), + app: app, + } +} diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 6e6695917d35..dafc3d80023d 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -1854,7 +1854,7 @@ func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { // Format line number dynamically based on the maximum number of lines. digits := len(strconv.Itoa(m.MaxHeight)) - str = fmt.Sprintf(" %*v ", digits, str) + str = fmt.Sprintf("%*v ", digits, str) return textStyle.Render(lineNumberStyle.Render(str)) } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index f730cbdf427a..1e95fc39d4a2 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -684,6 +684,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.ThemeSelectedMsg: a.app.State.Theme = msg.ThemeName cmds = append(cmds, a.app.SaveState()) + case dialog.ScratchpadUpdatedMsg: + // Handle scratchpad content updates + cmds = append(cmds, a.handleScratchpadUpdate(msg.Content)) case toast.ShowToastMsg: tm, cmd := a.toastManager.Update(msg) a.toastManager = tm @@ -878,9 +881,13 @@ func (a Model) View() (string, *tea.Cursor) { mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout) } - cursor := a.editor.Cursor() - cursor.Position.X += editorX - cursor.Position.Y += editorY + var cursor *tea.Cursor + if a.modal == nil { + // Only show cursor when no modal is open + cursor = a.editor.Cursor() + cursor.Position.X += editorX + cursor.Position.Y += editorY + } return mainLayout + "\n" + a.status.View(), cursor } @@ -1384,6 +1391,18 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { a.modal = themeDialog case commands.ProjectInitCommand: cmds = append(cmds, a.app.InitializeProject(context.Background())) + case commands.ScratchpadOpenCommand: + // Load system scratch content from server first + var scratchpadContent string + if a.app.Session != nil && a.app.Session.ID != "" { + a.app.LoadSessionScratchpad(context.Background()) + scratchpadContent = a.app.GetSessionScratchpad() + } else { + scratchpadContent = a.app.State.HomescreenScratchpad + } + scratchpadDialog := dialog.NewScratchpadDialog(a.app) + scratchpadDialog.SetContent(scratchpadContent) + a.modal = scratchpadDialog case commands.InputClearCommand: if a.editor.Value() == "" { return a, nil @@ -1521,3 +1540,16 @@ func formatConversationToMarkdown(messages []app.Message) string { return builder.String() } + +// handleScratchpadUpdate handles scratchpad content updates by saving to session +func (a Model) handleScratchpadUpdate(content string) tea.Cmd { + return func() tea.Msg { + if a.app.Session != nil && a.app.Session.ID != "" { + a.app.SaveSessionScratchpad(content) + } else { + a.app.State.HomescreenScratchpad = content + a.app.SaveState() // persist homescreen system scratch + } + return nil + } +}