@tstax/coding-tab
v0.2.8
Published
Drop-in Cursor-style coding agent tab for any Express app. Multiple persistent chats, plan/agent modes, in-line tool inspection, GitHub OAuth, opens PRs against your repo via the Cursor SDK cloud runtime.
Downloads
144
Maintainers
Readme
@tstax/coding-tab
Drop a Cursor-style coding agent tab into any Express app. Sign in with GitHub, run multiple persistent chats in Plan or Agent mode, inspect every tool call inline, then Merge & Redeploy to land a PR — and let your existing GitHub-to-Railway (or Vercel, or Render) auto-deploy ship the change.
Powered by @cursor/sdk cloud runtime, so the agent behaves the same as Cursor's own chat: real plan/agent loops, real file editing, real PRs.
What's new in 0.2
- Multiple chats at once. A left sidebar lists all your chats, each with its own conversation context. Switch between them at any time, even while one is streaming.
- Full persistence. Chat metadata, every turn, and every tool call (with args + result) is stored on disk. Refresh the tab and your history is right where you left it. Plug in your own database via the
storageoption. - Plan rendered as preview. Plan-mode answers render as proper markdown — headings, lists, code blocks, links — instead of raw text.
- Chronological timeline. Agent text and tool invocations are interleaved in the order they actually happened, with a clear paragraph break between each thought.
- Click into any tool call. Every
grep_search,read_file,task,shell, etc. is a collapsible row showing the full args and the full result.
Patch releases
0.2.8 — fixes a regression from 0.2.7's "preserve draft across re-renders" change: after clicking Send, the textarea would keep showing the just-sent message. The defensive captureComposerDraft() at the top of render() was re-reading the not-yet-replaced DOM textarea and resurrecting the draft that onSend had just cleared. The textarea's input listener already mirrors every keystroke into state, so the in-render capture was redundant; removing it fixes the clear without losing the "draft survives mode/model/chat switch" behavior.
0.2.7 — four mobile/composer UX upgrades:
- Photo & file attachments. A paperclip button in the composer opens the OS file picker; pick one or more images and they show up as removable thumbnails above the textarea. On send, each image is downscaled to 1600px on its longest edge via
<canvas>(so a 4000px camera photo doesn't blow out the SSE pipe), base64-encoded, and forwarded straight toagent.send({ text, images })as anSDKUserMessage. The images are persisted on the user turn and re-render as click-to-zoom thumbnails when you scroll back. Up to 8 images per message; PNG screenshots stay lossless, JPEGs recompress at q=0.85. The server's body limit defaults to25mb(configurable via the newbodyLimitmount option). - Composer draft now persists across plan/agent toggle, model dropdown change, chat switch, and every render triggered by streaming events. Each chat keeps its own draft + pending attachments, so jumping between threads doesn't make you retype anything. A small
__pending__slot also catches text the user typed before clicking "+ New chat" so the first message of a new chat survives the chat-create roundtrip. - Tap-outside-to-close sidebar on mobile. When the slide-over chat list is open, the area to the right is now a dimmed scrim — tap it once and the sidebar closes, matching every native iOS/Android drawer.
- More breathing room at the bottom. The composer's bottom padding now uses
max(14px, ...)instead of a flat 10px, so even on hosts that don't exposeenv(safe-area-inset-bottom)the Send button isn't flush against the rounded screen edge. The mobile sidebar's chat list also respects the safe-area inset so the last chat row doesn't sit under the iOS home indicator.
0.2.6 — fixes two visible bugs:
- Plan-mode no longer renders as a stream of fragments. The Cursor SDK streams text as small incremental deltas (each
assistantmessage is a chunk like"I have"," en","ough"); previous releases ended the text block after every push, persisting each delta as its own paragraph. 0.2.6 only ends the block when a tool call interrupts, so consecutive deltas coalesce. The client also coalesces at render time so chats persisted before the fix display correctly too. - Mobile composer no longer cuts off the buttons. On screens ≤720px the textarea now takes the full width (so the placeholder doesn't wrap into a tiny strip) and Send/Stop stack beneath it with a 40px tap target. Padding-bottom uses
env(safe-area-inset-bottom)so the iOS home indicator doesn't crop the buttons. The standalone/coding-tab/host page also gets explicithtml, body { height: 100dvh }.
0.2.5 — handles two more mobile-backgrounding failure modes that 0.2.4 missed:
- Upstream stream disconnects (
NetworkError,"Load failed","fetch failed","ECONNRESET", etc.) are no longer treated as fatal turn errors. The turn's status is preserved, no[error] Load failedtext gets persisted into the timeline, and a backgroundrun.wait()reattach with backoff updates the terminal status (and any PR/branch info) once the cloud run finishes. AgentBusyError(409) on a follow-up send is surfaced as a friendly "the agent is still working on the previous request…" message instead of[error] [agent_busy] Agent already has an active run. The empty assistant turn that would otherwise become noise is markedcancelled. If the chat ever stays wedged because Cursor's cloud is reporting a phantom active run, the user can recover by deleting the chat (trash icon) and starting a new one — that creates a fresh agent.
0.2.4 — auto-recover when the cached cloud agent has been GC'd: agent.send() failures with agent_not_found transparently drop the stale in-memory handle, clear the persisted agentId, and create a fresh agent on retry instead of surfacing [error] Agent not found to the user.
0.2.3 — survive mobile Safari backgrounding by polling instead of erroring.
0.2.2 — Agent-mode link rendering + GitHub fallback when PR creation is blocked by repo/team policy.
0.2.1 — mobile UX fixes + Merge & Redeploy button feedback (loading / success / error).
Install
npm install @tstax/coding-tabWhat you need
- Cursor API key — from https://cursor.com/dashboard/cloud-agents.
- A GitHub OAuth App for each app that embeds this tab (one-time, ~2 minutes per app):
- https://github.com/settings/developers → New OAuth App
- Authorization callback URL:
https://<your-app>/coding-tab/auth/callback - Copy the Client ID, generate a Client Secret.
- Node 20+ running an Express server.
- A writable directory for the default file store, OR your own
ChatStorageadapter (see Persistence).
Mount it on your server
import express from "express";
import { mountCodingTab } from "@tstax/coding-tab/server";
const app = express();
mountCodingTab(app, {
cursorApiKey: process.env.CURSOR_API_KEY!,
githubOAuth: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackUrl: `${process.env.PUBLIC_BASE_URL}/coding-tab/auth/callback`,
allowedLogins: (process.env.ALLOWED_GITHUB_LOGIN ?? "").split(",").filter(Boolean),
},
sessionPassword: process.env.SESSION_SECRET!, // 32+ chars: openssl rand -hex 32
defaultRepo: { url: "https://github.com/youruser/yourrepo" },
// Optional — defaults to `<cwd>/.coding-tab-data`
dataDir: process.env.CODING_TAB_DATA_DIR,
});
app.listen(3000);That's it on the server. Visit https://<your-app>/coding-tab/ and the tab is fully self-rendering. Or embed it inside your dashboard:
<div id="ct"></div>
<link rel="stylesheet" href="/coding-tab/style.css" />
<script src="/coding-tab/browser.js"></script>
<script>
CodingTab.mountCodingTab(document.getElementById("ct"), { apiBase: "/coding-tab" });
</script>Required env vars
| Var | What it is |
| --- | --- |
| CURSOR_API_KEY | API key from cursor.com/dashboard/cloud-agents |
| GITHUB_CLIENT_ID | From your OAuth App |
| GITHUB_CLIENT_SECRET | From your OAuth App |
| SESSION_SECRET | 32+ char random string (openssl rand -hex 32) |
| ALLOWED_GITHUB_LOGIN | Comma-separated GitHub usernames allowed to sign in |
| PUBLIC_BASE_URL | Your app's external URL, used to build the OAuth callback |
Attachments
The composer's paperclip button accepts any image/* file. Selected photos are:
- Read into memory via
FileReader.readAsDataURL. - Decoded into an
<img>and re-encoded through a<canvas>sized to at most 1600px on the longest edge (aspect ratio preserved). PNGs round-trip as PNG; everything else recompresses to JPEG at q=0.85. This keeps payloads in the low hundreds of KB for typical phone photos. - Sent to
/agent/sendas{ images: [{ mimeType, data }] }wheredatais base64 with nodata:prefix. - Forwarded to the SDK as an
SDKUserMessageso the model can see them. - Persisted on the user turn under
images: AttachedImage[]so they re-render when the chat is loaded back.
Limits, all enforced server-side:
- Max 8 images per message (extras are silently dropped).
- Each image is at most the chat's body limit's share — bump
bodyLimitonmountCodingTab(...)if your users routinely attach many large screenshots (default25mb).
Persistence
Each chat is keyed to a GitHub login and contains a chronological list of turns; each assistant turn is a chronological list of TimelineEvents (text paragraphs and tool invocations).
By default, this is stored as JSON files under dataDir (<cwd>/.coding-tab-data if you don't set it):
<dataDir>/
chats/<chatId>.json # full chat: metadata + every turn + every tool call
index/<login>.json # cached listing per user (rebuilt on demand if missing)Pluggable storage
The default file store is fine for a single-process app on a host with a real (or mounted) filesystem. For ephemeral hosts (Railway free tier, Vercel, etc.) or multi-instance deployments, plug in your own:
import type { ChatStorage } from "@tstax/coding-tab/server";
const supabaseStorage: ChatStorage = {
async listChats(login) { /* SELECT id, title, ... FROM chats WHERE login = $1 */ },
async loadChat(id, login) { /* … */ },
async createChat(chat) { /* … */ },
async patchChat(id, login, patch) { /* … */ },
async appendTurn(turn) { /* … */ },
async patchTurn(chatId, turnId, patch) { /* … */ },
async deleteChat(id, login) { /* … */ },
};
mountCodingTab(app, { ...config, storage: supabaseStorage });Resuming agents across restarts
Each chat persists the agentId of the cloud agent it last spoke to. When the user sends another message in that chat, the server calls Agent.resume(agentId) so the model picks up the same conversation. If the cloud agent has been garbage-collected, we fall back to Agent.create(); the persisted turn history still displays.
Repo selection
The repo the agent works against is whatever you pass in defaultRepo.url server-side. The client widget reads this from /auth/me and locks the URL field to that repo (rendered as a clickable org/repo pill in the header), so users can't accidentally point the agent at a different codebase. To change the repo, change the server config — there's intentionally no UI override.
Security model
- GitHub OAuth, single sign-in. The user signs in with GitHub once. The OAuth access token authenticates the session AND authorizes clone/push/PR/merge — no separate PAT to manage.
- Allowlist. Set
allowedLoginsto your GitHub username (and anyone else you trust). Anyone outside the list gets a 403 even with a valid GitHub login. - Per-user chat scoping. Every persistence call is scoped to the signed-in user's GitHub login; one user can never read or mutate another user's chats.
- HTTP-only signed encrypted cookie. Session is stored in an
iron-sessioncookie (coding_tab_session): JS on the page can't read the access token. Server decrypts on every request. - CSRF-protected handshake. OAuth uses a per-request
statecookie that expires in 10 minutes. - Cookie hardening.
HttpOnly,SameSite=Lax,Securein production.
Plan vs Agent mode
- Plan — agent runs read-only, produces a markdown plan ending in
PLAN READY. The plan renders as a proper preview (headings, lists, code, links). Click Execute plan to follow up with code changes in the same conversation. - Agent — agent goes straight to making changes and opens a PR (
autoCreatePR: true).
The conversation context is preserved across plan and execute turns, just like Cursor chat.
Models
The picker shows Sonnet and Opus, mapped to whatever Claude variants your Cursor account exposes via Cursor.models.list(). If your account stops offering Sonnet/Opus directly (Cursor occasionally changes what's available), the dropdown will only show what's accessible.
API surface (mounted at basePath, default /coding-tab)
Public:
GET /— host HTMLGET /style.css,GET /browser.js— assetsGET /auth/login— start OAuthGET /auth/callback— OAuth returnPOST /auth/logoutGET /auth/me— 401 if not signed in, otherwise{ githubLogin, avatarUrl?, defaultRepoUrl?, defaultRepoRef? }
Authenticated (require valid session):
GET /chats— list current user's chatsPOST /chats— create a new chat (returns{ chat })GET /chats/:id— full chat including all turnsPATCH /chats/:id— rename / change mode or modelDELETE /chats/:id— delete chat + dispose any in-memory agentGET /models— discovered Sonnet/Opus IDsPOST /agent/start— alias of/agent/sendPOST /agent/send— SSE stream; sends a turn into the given chat (creates or resumes the cloud agent)POST /agent/execute— SSE stream; turns the most recent plan into changesPOST /agent/cancel— cancel an in-flight run for a chatPOST /agent/dispose— release the in-memory agent for a chat (does not delete history)GET /pr/status?prUrl=...— PR metadata + mergeabilityPOST /pr/merge— merge a PR
Development
git clone https://github.com/tstax/coding-tab
cd coding-tab
npm install
npm run dev # tsup --watch
npm run lint # tsc --noEmit
npm testLicense
MIT
