Skip to content
Merged
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
57 changes: 57 additions & 0 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ const migrations = [
try { db.exec('DELETE FROM session_cache'); } catch {}
try { db.exec('DELETE FROM cache_meta'); } catch {}
},
// Upstream replaced its own v4 with a () => {} no-op and moved the fileMtime
// ALTER into the schema-reconciliation pass below. Our array is
// index-addressed with v4-v6 already shipped, so v7 stays as the fast path
// for fork installs; the reconciliation block covers foreign-version DBs.
];

const currentDbVersion = (() => {
Expand All @@ -213,6 +217,59 @@ if (migrations.length > currentDbVersion) {
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', ?)").run(JSON.stringify(migrations.length));
}

// --- Schema reconciliation ---
// Version-numbered migrations cannot be trusted to add columns: a DB already
// migrated to a HIGHER version by a build from a parallel branch skips this
// branch's migrations entirely (a db_version-5 DB from the subagent branch
// never ran our v4, so the fileMtime ALTER never happened and every prepare()
// below crashed the app at startup). Required columns are therefore ensured by
// inspecting the actual schema, independent of db_version. Errors here are
// deliberately NOT swallowed: a transient failure (e.g. SQLITE_BUSY) must not
// be recorded as migrated — the next launch simply retries.
{
const cols = new Set(db.prepare('PRAGMA table_info(session_cache)').all().map(c => c.name));
let mustReindex = false;
if (!cols.has('aiTitle')) db.exec('ALTER TABLE session_cache ADD COLUMN aiTitle TEXT');
// Fork columns (shipped in our v4): a foreign-version DB may have skipped
// that migration the same way. Their absence means subagent rows were never
// indexed, so a re-index is needed too.
for (const col of ['parentSessionId', 'agentId', 'subagentType', 'description']) {
if (!cols.has(col)) {
db.exec(`ALTER TABLE session_cache ADD COLUMN ${col} TEXT`);
mustReindex = true;
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_session_cache_parent ON session_cache(parentSessionId)');
// Fork table (shipped in our v5), referenced unconditionally by prepare()
// below — must exist whatever db_version claims.
db.exec(`CREATE TABLE IF NOT EXISTS session_metrics (
sessionId TEXT NOT NULL,
date TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
messageCount INTEGER DEFAULT 0,
toolCallCount INTEGER DEFAULT 0,
inputTokens INTEGER DEFAULT 0,
outputTokens INTEGER DEFAULT 0,
cacheReadTokens INTEGER DEFAULT 0,
cacheCreationTokens INTEGER DEFAULT 0,
PRIMARY KEY (sessionId, date, model)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_metrics_date ON session_metrics(date)');
if (!cols.has('fileMtime')) {
db.exec('ALTER TABLE session_cache ADD COLUMN fileMtime TEXT');
// fileMtime's introduction changed what `modified` means (file mtime →
// last-message timestamp), so cached values written by pre-fileMtime code
// are stale. Clear the cache to force a full re-index; without this,
// dormant folders would keep mtime-based times indefinitely because the
// folder-level index gate never re-reads them.
mustReindex = true;
}
if (mustReindex) {
db.exec('DELETE FROM session_cache');
db.exec('DELETE FROM cache_meta');
}
}

// --- FTS5 full-text search (external-content table) ---
//
// Body is capped at FTS_BODY_MAX_CHARS before being stored. This bounds the
Expand Down
3 changes: 2 additions & 1 deletion derive-project-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ function extractCwdFromJsonl(filePath) {
function resolveWorktreePath(cwd) {
if (!cwd) return cwd;
// Detect worktree paths: <project>/.claude-worktrees/<name>, <project>/.worktrees/<name>, or <project>/.claude/worktrees/<name>
const worktreeMatch = cwd.match(/^(.+?)\/\.(?:claude\/worktrees|claude-worktrees|worktrees)\/[^/]+\/?$/);
// Separators: accept both / and \ so Windows cwds collapse too.
const worktreeMatch = cwd.match(/^(.+?)[/\\]\.(?:claude[/\\]worktrees|claude-worktrees|worktrees)[/\\][^/\\]+[/\\]?$/);
if (worktreeMatch) {
const parent = worktreeMatch[1];
if (fs.existsSync(parent)) return parent;
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ const rendererCrossFileGlobals = {
isSessionNavKey: 'readonly',
fitAndScroll: 'readonly',
safeFit: 'readonly',
proposeFittedDimensions: 'readonly',
refitOpenTerminals: 'readonly',
syncPtySizeAfterOpen: 'readonly',
ptySizeChanged: 'readonly',
observeContainerResize: 'readonly',
flushTerminalBuffer: 'readonly',
scheduleFlush: 'readonly',
handleTerminalData: 'readonly',
Expand Down Expand Up @@ -314,6 +319,7 @@ module.exports = [
'derive-project-path.js',
'encode-project-path.js',
'folder-index-state.js',
'pty-size.js',
'claude-auth.js',
'mcp-bridge.js',
'schedule-ipc.js',
Expand Down
19 changes: 14 additions & 5 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslS
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath } = require('./ipc-path-validator');
const { normalizePtySize } = require('./pty-size');


// --- Auto-updater (only in packaged builds) ---
Expand Down Expand Up @@ -1514,7 +1515,7 @@ ipcMain.handle('archive-session', (_event, sessionId, archived) => {
});

// --- IPC: open-terminal ---
ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions) => {
ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions, initialSize) => {
if (!mainWindow) return { ok: false, error: 'no window' };

// Reattach to existing session
Expand Down Expand Up @@ -1547,6 +1548,14 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se
return { ok: false, error: `project directory no longer exists: ${projectPath}` };
}

// Spawn at the size the renderer actually measured for the terminal
// container. Spawning at a fixed 120x30 and only correcting on the first
// terminal-resize left every session running against a wrong width until
// then — the shell wrapped its output for 120 columns while xterm displayed
// another, and TUI cursor moves landed a line off. normalizePtySize falls
// back to the historical 120x30 when the renderer could not measure.
const { cols: spawnCols, rows: spawnRows } = normalizePtySize(initialSize);

const isPlainTerminal = sessionOptions?.type === 'terminal';

// Resolve shell profile from effective settings
Expand Down Expand Up @@ -1615,8 +1624,8 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se
const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;';
ptyProcess = pty.spawn(shell, shellArgs(shell, undefined, shellExtraArgs), {
name: 'xterm-256color',
cols: 120,
rows: 30,
cols: spawnCols,
rows: spawnRows,
cwd: isWsl ? os.homedir() : projectPath,
env: {
...cleanPtyEnv,
Expand Down Expand Up @@ -1712,8 +1721,8 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se

ptyProcess = pty.spawn(shell, shellArgs(shell, claudeCmd, shellExtraArgs), {
name: 'xterm-256color',
cols: 120,
rows: 30,
cols: spawnCols,
rows: spawnRows,
cwd: isWsl ? os.homedir() : projectPath,
// TERM_PROGRAM=iTerm.app: Claude Code checks this to decide whether to emit
// OSC 9 notifications (e.g. "needs your attention"). Without it, the packaged
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "switchboard",
"version": "0.0.41",
"version": "0.0.42",
"private": true,
"description": "A desktop app for browsing, searching, and managing CLI coding sessions",
"author": "Doctly <support@doctly.ai>",
Expand Down
4 changes: 3 additions & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ contextBridge.exposeInMainWorld('api', {
toggleStar: (id) => ipcRenderer.invoke('toggle-star', id),
renameSession: (id, name) => ipcRenderer.invoke('rename-session', id, name),
archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived),
openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions),
// initialSize: { cols, rows } measured by the renderer before the spawn, so
// the PTY never runs against a placeholder geometry. May be null.
openTerminal: (id, projectPath, isNew, sessionOptions, initialSize) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions, initialSize),
search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly),
readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId),
readSubagentJsonl: (parentSessionId, agentId) => ipcRenderer.invoke('read-subagent-jsonl', parentSessionId, agentId),
Expand Down
54 changes: 54 additions & 0 deletions pty-size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Normalisation of the terminal size the renderer asks a PTY to be spawned
// with.
//
// Why this exists: the PTY used to be spawned at a hard-coded 120x30 and only
// learned its real geometry from the first terminal-resize IPC. Until then the
// shell wrapped its output against a width the display did not have, which is
// what makes a TUI's cursor land one line off. The renderer now measures its
// container before asking for the spawn (see proposeFittedDimensions in
// public/terminal-manager.js) and passes the result here.
//
// The value crosses an IPC boundary, so it is treated as untrusted input:
// anything missing, non-finite or out of range falls back to the historical
// defaults rather than reaching node-pty.

const DEFAULT_COLS = 120;
const DEFAULT_ROWS = 30;

// Lower bounds match what xterm itself enforces (FitAddon never proposes fewer
// than 2 cols / 1 row). Upper bounds are a sanity ceiling: no real display
// reaches them, and node-pty/ConPTY allocate a buffer proportional to them.
const MIN_COLS = 2;
const MIN_ROWS = 1;
const MAX_COLS = 1000;
const MAX_ROWS = 500;

function coerce(value, min, max, fallback) {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
const i = Math.floor(n);
if (i < min || i > max) return fallback;
return i;
}

// Returns { cols, rows } — always usable, never throws.
// `size` is whatever the renderer sent: { cols, rows }, null, or garbage.
function normalizePtySize(size) {
if (!size || typeof size !== 'object') {
return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS };
}
return {
cols: coerce(size.cols, MIN_COLS, MAX_COLS, DEFAULT_COLS),
rows: coerce(size.rows, MIN_ROWS, MAX_ROWS, DEFAULT_ROWS),
};
}

module.exports = {
normalizePtySize,
DEFAULT_COLS,
DEFAULT_ROWS,
MIN_COLS,
MIN_ROWS,
MAX_COLS,
MAX_ROWS,
};
34 changes: 20 additions & 14 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -981,13 +981,14 @@ async function launchNewSession(project, sessionOptions) {
const entry = createTerminalEntry(session);

// Open terminal in main process with session options
const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null);
const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null, entry.initialSize);
if (!result.ok) {
entry.terminal.write(`\r\nError: ${result.error}\r\n`);
entry.closed = true;
showSession(sessionId);
return;
}
syncPtySizeAfterOpen(entry);
if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive);

showSession(sessionId);
Expand Down Expand Up @@ -1049,33 +1050,38 @@ async function openSession(session, customOptions) {

// Open terminal in main process
const resumeOptions = customOptions || await resolveDefaultSessionOptions({ projectPath });
const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions);
const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize);
if (!result.ok) {
entry.terminal.write(`\r\nError: ${result.error}\r\n`);
entry.closed = true;
showSession(sessionId);
return;
}
syncPtySizeAfterOpen(entry);
if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive);

showSession(sessionId);
schedulePersistWorkingSet();
pollActiveSessions();
}

// Handle window resize
window.addEventListener('resize', () => {
if (gridViewActive) {
for (const entry of openSessions.values()) {
fitAndScroll(entry);
}
return;
}
if (activeSessionId && openSessions.has(activeSessionId)) {
const entry = openSessions.get(activeSessionId);
safeFit(entry);
}
// --- Terminal geometry re-sync ---
// All three listeners funnel into refitOpenTerminals() (terminal-manager.js),
// which only sends a resize to the PTY when the measured size actually changed.
// No polling: these are pure event handlers and cost nothing while idle.

// Window resize — the historical hook (window drag, sidebar drag, maximise).
window.addEventListener('resize', refitOpenTerminals);

// Safety net for the geometry changes that fire no 'resize' on this window:
// returning from sleep, the window being moved to a screen with a different
// DPI, or the renderer being un-hidden after Chromium throttled it. Each one
// used to leave xterm and the PTY disagreeing on the width until the user
// happened to resize the window or switch session.
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refitOpenTerminals();
});
window.addEventListener('focus', refitOpenTerminals);

// --- Tab switching ---
document.querySelectorAll('.sidebar-tab').forEach(tab => {
Expand Down
6 changes: 4 additions & 2 deletions public/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ async function launchScheduleCreator(project) {
const entry = createTerminalEntry(session);
// Resume the pre-seeded session
options.appendSystemPrompt = result.systemPrompt;
const openResult = await window.api.openTerminal(result.sessionId, project.projectPath, false, options);
const openResult = await window.api.openTerminal(result.sessionId, project.projectPath, false, options, entry.initialSize);
if (!openResult.ok) {
entry.terminal.write(`\r\nError: ${openResult.error}\r\n`);
entry.closed = true;
return;
}
syncPtySizeAfterOpen(entry);
if (typeof setSessionMcpActive === 'function') setSessionMcpActive(result.sessionId, !!openResult.mcpActive);
showSession(result.sessionId);
pollActiveSessions();
Expand Down Expand Up @@ -158,12 +159,13 @@ async function launchTerminalSession(project) {

const entry = createTerminalEntry(session);

const result = await window.api.openTerminal(sessionId, projectPath, true, { type: 'terminal' });
const result = await window.api.openTerminal(sessionId, projectPath, true, { type: 'terminal' }, entry.initialSize);
if (!result.ok) {
entry.terminal.write(`\r\nError: ${result.error}\r\n`);
entry.closed = true;
return;
}
syncPtySizeAfterOpen(entry);

showSession(sessionId);
pollActiveSessions();
Expand Down
11 changes: 11 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,17 @@ body { display: flex; flex-direction: column; }
display: block;
}

/* Transient state used by createTerminalEntry: lay the container out so xterm
can measure its cell size and FitAddon can propose real dimensions BEFORE
the PTY is spawned, without ever painting it. display:none would give a 0x0
measurement (the reason the PTY used to be spawned at a hard-coded 120x30);
visibility:hidden keeps the box out of sight, and position:absolute (from
.terminal-container) keeps siblings from reflowing. */
.terminal-container.measuring {
display: block;
visibility: hidden;
}

.terminal-container.drag-over {
outline: 2px solid rgba(128, 136, 255, 0.6);
outline-offset: -2px;
Expand Down
Loading
Loading