From a6ef5564adb88913bb62cf301c75d6f1fd3ba9d2 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:41:14 +0200 Subject: [PATCH 1/6] feat(tui): session scratchpad `w` to store notes, todos, system prompt extensions etc... --- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/server.ts | 67 ++++++++++ packages/opencode/src/session/index.ts | 1 + packages/sdk/go/session.go | 72 +++++++++-- packages/tui/internal/app/app.go | 84 ++++++++++++- packages/tui/internal/app/state.go | 21 ++-- packages/tui/internal/commands/command.go | 70 ++++++----- .../components/dialog/systemScratch.go | 119 ++++++++++++++++++ packages/tui/internal/tui/tui.go | 28 +++++ 9 files changed, 410 insertions(+), 53 deletions(-) create mode 100644 packages/tui/internal/components/dialog/systemScratch.go diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5f3edca7ebfe..7afc0c53c4ab 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"), + system_scratchpad_open: z.string().optional().default("w").describe("Open session system scratch pad"), 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..e173dfd1d497 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/systemScratch", + describeRoute({ + description: "Get session system scratchpad content", + operationId: "session.systemScratch.get", + responses: { + 200: { + description: "System scratch content", + content: { + "application/json": { + schema: resolver(z.object({ systemScratch: 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({ systemScratch: session?.systemScratch || "" }) + }, + ) + .put( + "/session/:id/systemScratch", + describeRoute({ + description: "Update session system scratchpad content", + operationId: "session.systemScratch.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({ + systemScratch: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").id + const { systemScratch } = c.req.valid("json") + + const updatedSession = await Session.update(sessionID, (session) => { + session.systemScratch = systemScratch + }) + + 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..95cf5493e9e4 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(), + systemScratch: z.string().optional(), }) .openapi({ ref: "Session", diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index 9cc0492cf766..083737861fc5 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 system scratch content +func (r *SessionService) SystemScratchGet(ctx context.Context, id string, opts ...option.RequestOption) (res *SessionSystemScratchGetResponse, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/systemScratch", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Update session system scratch content +func (r *SessionService) SystemScratchUpdate(ctx context.Context, id string, body SessionSystemScratchUpdateParams, 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/systemScratch", 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"` + SystemScratch string `json:"systemScratch"` + 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 SessionSystemScratchGetResponse struct { + SystemScratch string `json:"systemScratch,required"` + JSON sessionSystemScratchGetResponseJSON `json:"-"` +} + +type sessionSystemScratchGetResponseJSON struct { + SystemScratch apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r sessionSystemScratchGetResponseJSON) RawJSON() string { + return r.raw +} + +func (r *SessionSystemScratchGetResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionSystemScratchGetResponseJSON) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +type SessionSystemScratchUpdateParams struct { + SystemScratch param.Field[string] `json:"systemScratch,required"` +} + +func (r SessionSystemScratchUpdateParams) 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..b155e66b18d5 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.HomescreenSystemScratch != "" { + _, err := a.Client.Session.SystemScratchUpdate(ctx, session.ID, opencode.SessionSystemScratchUpdateParams{ + SystemScratch: opencode.F(a.State.HomescreenSystemScratch), + }) + if err == nil { + session.SystemScratch = a.State.HomescreenSystemScratch + a.State.HomescreenSystemScratch = "" + 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,55 @@ func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) return nil } +func (a *App) GetSessionSystemScratch() string { + if a.Session == nil { + return "" + } + return a.Session.SystemScratch +} + +func (a *App) GetSessionScratchpad() string { + if a.Session == nil { + return "" + } + return a.Session.SystemScratch +} + +func (a *App) SaveSessionSystemScratch(content string) error { + if a.Session == nil { + return fmt.Errorf("no active session") + } + + ctx := context.Background() + _, err := a.Client.Session.SystemScratchUpdate(ctx, a.Session.ID, opencode.SessionSystemScratchUpdateParams{ + SystemScratch: opencode.F(content), + }) + if err != nil { + slog.Error("Failed to save system scratch", "error", err) + return err + } + + // Update local session + a.Session.SystemScratch = content + return nil +} + +func (a *App) LoadSessionSystemScratch(ctx context.Context) error { + if a.Session == nil { + return fmt.Errorf("no active session") + } + + response, err := a.Client.Session.SystemScratchGet(ctx, a.Session.ID) + if err != nil { + slog.Error("Failed to load system scratch", "error", err) + return err + } + + // Update local session + a.Session.SystemScratch = response.SystemScratch + 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..cd4b2b1b0b82 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"` + HomescreenSystemScratch string `toml:"homescreen_system_scratch"` // 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..068d067049ef 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" + SystemScratchOpenCommand CommandName = "system_scratch_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: SystemScratchOpenCommand, + 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/systemScratch.go b/packages/tui/internal/components/dialog/systemScratch.go new file mode 100644 index 000000000000..efe4eb9f24e3 --- /dev/null +++ b/packages/tui/internal/components/dialog/systemScratch.go @@ -0,0 +1,119 @@ +package dialog + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/sst/opencode/internal/app" + "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" +) + +// SystemScratchUpdatedMsg is sent when system scratch content is updated +type SystemScratchUpdatedMsg struct { + Content string +} + +// SystemScratchDialog interface for the system scratch modal +type SystemScratchDialog interface { + layout.Modal + GetContent() string + SetContent(content string) +} + +type systemScratchDialog struct { + width int + height int + modal *modal.Modal + textarea textarea.Model + app *app.App +} + +func (n *systemScratchDialog) Init() tea.Cmd { + return n.textarea.Focus() +} + +func (n *systemScratchDialog) 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(SystemScratchUpdatedMsg{Content: content}), + ) + } + } + + var cmd tea.Cmd + n.textarea, cmd = n.textarea.Update(msg) + return n, cmd +} + +func (n *systemScratchDialog) 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 *systemScratchDialog) Close() tea.Cmd { + // Save content when closing + content := strings.TrimSpace(n.textarea.Value()) + return util.CmdHandler(SystemScratchUpdatedMsg{Content: content}) +} + +func (n *systemScratchDialog) GetContent() string { + return n.textarea.Value() +} + +func (n *systemScratchDialog) SetContent(content string) { + n.textarea.SetValue(content) +} + +// NewSystemScratchDialog creates a new system scratch modal dialog +func NewSystemScratchDialog(app *app.App) SystemScratchDialog { + 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 persists for the session and is shared with the agent." + + // 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). + Lipgloss() + ta.Styles.Blurred.Base = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() + + return &systemScratchDialog{ + textarea: ta, + modal: modal.New(modal.WithTitle("Scratchpad"), modal.WithMaxWidth(90)), + app: app, + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index f730cbdf427a..c91a43b73eb0 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.SystemScratchUpdatedMsg: + // Handle scratchpad content updates + cmds = append(cmds, a.handleScratchpadUpdate(msg.Content)) case toast.ShowToastMsg: tm, cmd := a.toastManager.Update(msg) a.toastManager = tm @@ -1384,6 +1387,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.SystemScratchOpenCommand: + // Load system scratch content from server first + var systemScratchContent string + if a.app.Session != nil && a.app.Session.ID != "" { + a.app.LoadSessionSystemScratch(context.Background()) + systemScratchContent = a.app.GetSessionSystemScratch() + } else { + systemScratchContent = a.app.State.HomescreenSystemScratch + } + systemScratchDialog := dialog.NewSystemScratchDialog(a.app) + systemScratchDialog.SetContent(systemScratchContent) + a.modal = systemScratchDialog case commands.InputClearCommand: if a.editor.Value() == "" { return a, nil @@ -1521,3 +1536,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.SaveSessionSystemScratch(content) + } else { + a.app.State.HomescreenSystemScratch = content + a.app.SaveState() // persist homescreen system scratch + } + return nil + } +} From a75a10c61740e571e1622c836851c09a11bc8858 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:11:52 +0200 Subject: [PATCH 2/6] small wording change --- packages/tui/internal/components/dialog/systemScratch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/components/dialog/systemScratch.go b/packages/tui/internal/components/dialog/systemScratch.go index efe4eb9f24e3..9a91da9951fd 100644 --- a/packages/tui/internal/components/dialog/systemScratch.go +++ b/packages/tui/internal/components/dialog/systemScratch.go @@ -97,7 +97,7 @@ func NewSystemScratchDialog(app *app.App) SystemScratchDialog { 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 persists for the session and is shared with the agent." + 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." // Style the textarea ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() From cd46d56c2a71dba1775eaf6b59c4a2139f0a209d Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 16 Aug 2025 20:24:08 +0200 Subject: [PATCH 3/6] allowing pasting into the scratchpad --- .../components/dialog/systemScratch.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/tui/internal/components/dialog/systemScratch.go b/packages/tui/internal/components/dialog/systemScratch.go index 9a91da9951fd..7b98cc1d01fa 100644 --- a/packages/tui/internal/components/dialog/systemScratch.go +++ b/packages/tui/internal/components/dialog/systemScratch.go @@ -5,6 +5,7 @@ import ( 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" @@ -53,7 +54,25 @@ func (n *systemScratchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(SystemScratchUpdatedMsg{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 From dcb8ef10da83eec9334cf2b81726c83e6f09fe44 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 16 Aug 2025 20:36:01 +0200 Subject: [PATCH 4/6] changing semantic to scratchpad --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/server/server.ts | 18 ++++---- packages/opencode/src/session/index.ts | 2 +- packages/sdk/go/session.go | 34 +++++++------- packages/tui/internal/app/app.go | 34 ++++++-------- packages/tui/internal/app/state.go | 2 +- packages/tui/internal/commands/command.go | 4 +- .../{systemScratch.go => scratchpad.go} | 46 +++++++++---------- packages/tui/internal/tui/tui.go | 22 ++++----- 9 files changed, 79 insertions(+), 85 deletions(-) rename packages/tui/internal/components/dialog/{systemScratch.go => scratchpad.go} (77%) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7afc0c53c4ab..80e976475c15 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -224,7 +224,7 @@ export namespace Config { .optional() .default("ctrl+left") .describe("Cycle to previous child session"), - system_scratchpad_open: z.string().optional().default("w").describe("Open session system scratch pad"), + 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 e173dfd1d497..68190384f673 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -751,16 +751,16 @@ export namespace Server { }, ) .get( - "/session/:id/systemScratch", + "/session/:id/scratchpad", describeRoute({ description: "Get session system scratchpad content", - operationId: "session.systemScratch.get", + operationId: "session.scratchpad.get", responses: { 200: { description: "System scratch content", content: { "application/json": { - schema: resolver(z.object({ systemScratch: z.string() })), + schema: resolver(z.object({ scratchpad: z.string() })), }, }, }, @@ -775,14 +775,14 @@ export namespace Server { async (c) => { const sessionID = c.req.valid("param").id const session = await Session.get(sessionID) - return c.json({ systemScratch: session?.systemScratch || "" }) + return c.json({ scratchpad: session?.scratchpad || "" }) }, ) .put( - "/session/:id/systemScratch", + "/session/:id/scratchpad", describeRoute({ description: "Update session system scratchpad content", - operationId: "session.systemScratch.update", + operationId: "session.scratchpad.update", responses: { 200: { description: "System scratch updated successfully", @@ -803,15 +803,15 @@ export namespace Server { zValidator( "json", z.object({ - systemScratch: z.string(), + scratchpad: z.string(), }), ), async (c) => { const sessionID = c.req.valid("param").id - const { systemScratch } = c.req.valid("json") + const { scratchpad } = c.req.valid("json") const updatedSession = await Session.update(sessionID, (session) => { - session.systemScratch = systemScratch + session.scratchpad = scratchpad }) return c.json(updatedSession) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 95cf5493e9e4..46ac258c02df 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -86,7 +86,7 @@ export namespace Session { diff: z.string().optional(), }) .optional(), - systemScratch: z.string().optional(), + scratchpad: z.string().optional(), }) .openapi({ ref: "Session", diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index 083737861fc5..63e9faf679aa 100644 --- a/packages/sdk/go/session.go +++ b/packages/sdk/go/session.go @@ -238,26 +238,26 @@ func (r *SessionService) Unshare(ctx context.Context, id string, opts ...option. return } -// Get session system scratch content -func (r *SessionService) SystemScratchGet(ctx context.Context, id string, opts ...option.RequestOption) (res *SessionSystemScratchGetResponse, err error) { +// 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/systemScratch", id) + 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) SystemScratchUpdate(ctx context.Context, id string, body SessionSystemScratchUpdateParams, opts ...option.RequestOption) (res *Session, err error) { +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/systemScratch", id) + path := fmt.Sprintf("session/%s/scratchpad", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPut, path, body, &res, opts...) return } @@ -1310,7 +1310,7 @@ type Session struct { Time SessionTime `json:"time,required"` Title string `json:"title,required"` Version string `json:"version,required"` - SystemScratch string `json:"systemScratch"` + Scratchpad string `json:"scratchpad"` ParentID string `json:"parentID"` Revert SessionRevert `json:"revert"` Share SessionShare `json:"share"` @@ -2458,33 +2458,33 @@ func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } -type SessionSystemScratchGetResponse struct { - SystemScratch string `json:"systemScratch,required"` - JSON sessionSystemScratchGetResponseJSON `json:"-"` +type SessionScratchpadGetResponse struct { + Scratchpad string `json:"scratchpad,required"` + JSON sessionScratchpadGetResponseJSON `json:"-"` } -type sessionSystemScratchGetResponseJSON struct { - SystemScratch apijson.Field +type sessionScratchpadGetResponseJSON struct { + Scratchpad apijson.Field raw string ExtraFields map[string]apijson.Field } -func (r sessionSystemScratchGetResponseJSON) RawJSON() string { +func (r sessionScratchpadGetResponseJSON) RawJSON() string { return r.raw } -func (r *SessionSystemScratchGetResponse) UnmarshalJSON(data []byte) (err error) { +func (r *SessionScratchpadGetResponse) UnmarshalJSON(data []byte) (err error) { return apijson.UnmarshalRoot(data, r) } -func (r sessionSystemScratchGetResponseJSON) MarshalJSON() (data []byte, err error) { +func (r sessionScratchpadGetResponseJSON) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } -type SessionSystemScratchUpdateParams struct { - SystemScratch param.Field[string] `json:"systemScratch,required"` +type SessionScratchpadUpdateParams struct { + Scratchpad param.Field[string] `json:"scratchpad,required"` } -func (r SessionSystemScratchUpdateParams) MarshalJSON() (data []byte, err error) { +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 b155e66b18d5..1a01b0731697 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -734,13 +734,13 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { return nil, err } // Transfer homescreen system scratch content if present - if a.State.HomescreenSystemScratch != "" { - _, err := a.Client.Session.SystemScratchUpdate(ctx, session.ID, opencode.SessionSystemScratchUpdateParams{ - SystemScratch: opencode.F(a.State.HomescreenSystemScratch), + if a.State.HomescreenScratchpad != "" { + _, err := a.Client.Session.ScratchpadUpdate(ctx, session.ID, opencode.SessionScratchpadUpdateParams{ + Scratchpad: opencode.F(a.State.HomescreenScratchpad), }) if err == nil { - session.SystemScratch = a.State.HomescreenSystemScratch - a.State.HomescreenSystemScratch = "" + session.Scratchpad = a.State.HomescreenScratchpad + a.State.HomescreenScratchpad = "" a.SaveState() } } @@ -879,28 +879,22 @@ func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) return nil } -func (a *App) GetSessionSystemScratch() string { - if a.Session == nil { - return "" - } - return a.Session.SystemScratch -} - func (a *App) GetSessionScratchpad() string { if a.Session == nil { return "" } - return a.Session.SystemScratch + return a.Session.Scratchpad } -func (a *App) SaveSessionSystemScratch(content string) error { + +func (a *App) SaveSessionScratchpad(content string) error { if a.Session == nil { return fmt.Errorf("no active session") } ctx := context.Background() - _, err := a.Client.Session.SystemScratchUpdate(ctx, a.Session.ID, opencode.SessionSystemScratchUpdateParams{ - SystemScratch: opencode.F(content), + _, 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) @@ -908,23 +902,23 @@ func (a *App) SaveSessionSystemScratch(content string) error { } // Update local session - a.Session.SystemScratch = content + a.Session.Scratchpad = content return nil } -func (a *App) LoadSessionSystemScratch(ctx context.Context) error { +func (a *App) LoadSessionScratchpad(ctx context.Context) error { if a.Session == nil { return fmt.Errorf("no active session") } - response, err := a.Client.Session.SystemScratchGet(ctx, a.Session.ID) + 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.SystemScratch = response.SystemScratch + a.Session.Scratchpad = response.Scratchpad return nil } diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go index cd4b2b1b0b82..d8bc2e28acab 100644 --- a/packages/tui/internal/app/state.go +++ b/packages/tui/internal/app/state.go @@ -27,7 +27,7 @@ type AgentModel struct { } type State struct { - HomescreenSystemScratch string `toml:"homescreen_system_scratch"` // persists system scratch when no session is active + 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"` diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 068d067049ef..89956392f250 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -151,7 +151,7 @@ const ( MessagesCopyCommand CommandName = "messages_copy" MessagesUndoCommand CommandName = "messages_undo" MessagesRedoCommand CommandName = "messages_redo" - SystemScratchOpenCommand CommandName = "system_scratch_open" + ScratchpadOpenCommand CommandName = "scratchpad_open" AppExitCommand CommandName = "app_exit" ) @@ -310,7 +310,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Trigger: []string{"init"}, }, { - Name: SystemScratchOpenCommand, + Name: ScratchpadOpenCommand, Description: "open session system scratch pad", Keybindings: parseBindings("w"), Trigger: []string{"scratchpad", "notes", "scratch", "pad"}, diff --git a/packages/tui/internal/components/dialog/systemScratch.go b/packages/tui/internal/components/dialog/scratchpad.go similarity index 77% rename from packages/tui/internal/components/dialog/systemScratch.go rename to packages/tui/internal/components/dialog/scratchpad.go index 7b98cc1d01fa..edb854a22809 100644 --- a/packages/tui/internal/components/dialog/systemScratch.go +++ b/packages/tui/internal/components/dialog/scratchpad.go @@ -14,31 +14,31 @@ import ( "github.com/sst/opencode/internal/util" ) -// SystemScratchUpdatedMsg is sent when system scratch content is updated -type SystemScratchUpdatedMsg struct { +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 } -// SystemScratchDialog interface for the system scratch modal -type SystemScratchDialog interface { +// ScratchpadDialog interface for the system scratch modal +type ScratchpadDialog interface { layout.Modal GetContent() string SetContent(content string) } -type systemScratchDialog struct { - width int - height int - modal *modal.Modal - textarea textarea.Model - app *app.App -} - -func (n *systemScratchDialog) Init() tea.Cmd { +func (n *scratchpadDialog) Init() tea.Cmd { return n.textarea.Focus() } -func (n *systemScratchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (n *scratchpadDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: n.width = msg.Width @@ -52,7 +52,7 @@ func (n *systemScratchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { content := strings.TrimSpace(n.textarea.Value()) return n, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), - util.CmdHandler(SystemScratchUpdatedMsg{Content: content}), + util.CmdHandler(ScratchpadUpdatedMsg{Content: content}), ) case "ctrl+v", "super+v": // Handle paste directly using clipboard @@ -80,7 +80,7 @@ func (n *systemScratchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return n, cmd } -func (n *systemScratchDialog) Render(background string) string { +func (n *scratchpadDialog) Render(background string) string { view := n.textarea.View() helpText := styles.NewStyle(). Foreground(theme.CurrentTheme().TextMuted()). @@ -90,22 +90,22 @@ func (n *systemScratchDialog) Render(background string) string { return n.modal.Render(content, background) } -func (n *systemScratchDialog) Close() tea.Cmd { +func (n *scratchpadDialog) Close() tea.Cmd { // Save content when closing content := strings.TrimSpace(n.textarea.Value()) - return util.CmdHandler(SystemScratchUpdatedMsg{Content: content}) + return util.CmdHandler(ScratchpadUpdatedMsg{Content: content}) } -func (n *systemScratchDialog) GetContent() string { +func (n *scratchpadDialog) GetContent() string { return n.textarea.Value() } -func (n *systemScratchDialog) SetContent(content string) { +func (n *scratchpadDialog) SetContent(content string) { n.textarea.SetValue(content) } -// NewSystemScratchDialog creates a new system scratch modal dialog -func NewSystemScratchDialog(app *app.App) SystemScratchDialog { +// NewScratchpadDialog creates a new system scratch modal dialog +func NewScratchpadDialog(app *app.App) ScratchpadDialog { t := theme.CurrentTheme() bgColor := t.BackgroundPanel() textColor := t.Text() @@ -130,7 +130,7 @@ func NewSystemScratchDialog(app *app.App) SystemScratchDialog { Background(bgColor). Lipgloss() - return &systemScratchDialog{ + return &scratchpadDialog{ textarea: ta, modal: modal.New(modal.WithTitle("Scratchpad"), modal.WithMaxWidth(90)), app: app, diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index c91a43b73eb0..d11126da1b65 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -684,7 +684,7 @@ 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.SystemScratchUpdatedMsg: + case dialog.ScratchpadUpdatedMsg: // Handle scratchpad content updates cmds = append(cmds, a.handleScratchpadUpdate(msg.Content)) case toast.ShowToastMsg: @@ -1387,18 +1387,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.SystemScratchOpenCommand: + case commands.ScratchpadOpenCommand: // Load system scratch content from server first - var systemScratchContent string + var scratchpadContent string if a.app.Session != nil && a.app.Session.ID != "" { - a.app.LoadSessionSystemScratch(context.Background()) - systemScratchContent = a.app.GetSessionSystemScratch() + a.app.LoadSessionScratchpad(context.Background()) + scratchpadContent = a.app.GetSessionScratchpad() } else { - systemScratchContent = a.app.State.HomescreenSystemScratch + scratchpadContent = a.app.State.HomescreenScratchpad } - systemScratchDialog := dialog.NewSystemScratchDialog(a.app) - systemScratchDialog.SetContent(systemScratchContent) - a.modal = systemScratchDialog + scratchpadDialog := dialog.NewScratchpadDialog(a.app) + scratchpadDialog.SetContent(scratchpadContent) + a.modal = scratchpadDialog case commands.InputClearCommand: if a.editor.Value() == "" { return a, nil @@ -1541,9 +1541,9 @@ func formatConversationToMarkdown(messages []app.Message) string { func (a Model) handleScratchpadUpdate(content string) tea.Cmd { return func() tea.Msg { if a.app.Session != nil && a.app.Session.ID != "" { - a.app.SaveSessionSystemScratch(content) + a.app.SaveSessionScratchpad(content) } else { - a.app.State.HomescreenSystemScratch = content + a.app.State.HomescreenScratchpad = content a.app.SaveState() // persist homescreen system scratch } return nil From 3630d7318af6b4a8d5c28204ee25042057b23a5b Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:23:40 +0200 Subject: [PATCH 5/6] removing border and extra space --- packages/tui/internal/components/dialog/scratchpad.go | 9 +++++++++ packages/tui/internal/components/textarea/textarea.go | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/tui/internal/components/dialog/scratchpad.go b/packages/tui/internal/components/dialog/scratchpad.go index edb854a22809..892befbbb83b 100644 --- a/packages/tui/internal/components/dialog/scratchpad.go +++ b/packages/tui/internal/components/dialog/scratchpad.go @@ -117,6 +117,7 @@ func NewScratchpadDialog(app *app.App) ScratchpadDialog { 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() @@ -124,10 +125,18 @@ func NewScratchpadDialog(app *app.App) ScratchpadDialog { 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{ 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)) } From 44d0a4cf67390ad99cc3f8d6759f46c71badd5ac Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:35:18 +0200 Subject: [PATCH 6/6] hiding cursor when a.modal is open --- packages/tui/internal/tui/tui.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index d11126da1b65..1e95fc39d4a2 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -881,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 }