feat(terminal): add forced redraw resize

This commit is contained in:
Teigen
2026-06-17 23:41:47 +08:00
parent b374121c18
commit 2c81bbc08b
10 changed files with 82 additions and 28 deletions
+3 -2
View File
@@ -2321,7 +2321,7 @@ export class Session extends EventEmitter {
* @param cols - Number of columns (width in characters) * @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines) * @param rows - Number of rows (height in lines)
*/ */
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void { resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet'; const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') { if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows }; this._lastDesktopDims = { cols, rows };
@@ -2334,7 +2334,8 @@ export class Session extends EventEmitter {
} }
this._mobileSizeOverride = true; this._mobileSizeOverride = true;
} }
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) { const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
this._ptyCols = cols; this._ptyCols = cols;
this._ptyRows = rows; this._ptyRows = rows;
if (this._mux && this._muxSession) { if (this._mux && this._muxSession) {
+8
View File
@@ -116,6 +116,7 @@
<span class="stat-value" id="statMem">--</span> <span class="stat-value" id="statMem">--</span>
</div> </div>
</div> </div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button> <button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false"> <button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
@@ -1058,6 +1059,13 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
<span class="settings-item-label">Redraw Terminal Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowRedrawButton">
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section --> <!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div> <div class="settings-section-header">Tab Bar</div>
+10 -1
View File
@@ -318,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input) // Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -1417,6 +1418,7 @@ Object.assign(CodemanApp.prototype, {
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -1734,6 +1736,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: false, showMultiMonitorButton: false,
showPlanUsageLimits: false, showPlanUsageLimits: false,
showAttachmentsButton: false, showAttachmentsButton: false,
showRedrawButton: false,
// Input // Input
gestureControlEnabled: false, gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile // Feature toggles - keep tracking on even on mobile
@@ -1873,6 +1876,12 @@ Object.assign(CodemanApp.prototype, {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits); planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
} }
const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
const redrawBtn = document.querySelector('.btn-redraw-terminal');
if (redrawBtn) {
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
}
// Notification bell is retired (notifications live in Settings → Notifications // Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state. // + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications'); const notifBtn = document.querySelector('.btn-notifications');
@@ -2106,7 +2115,7 @@ Object.assign(CodemanApp.prototype, {
// are NOT display keys — they control server-side behavior and must sync from server. // are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([ const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
+12 -7
View File
@@ -1973,9 +1973,10 @@ Object.assign(CodemanApp.prototype, {
} }
try { try {
// Send resize to restore proper dimensions (with minimum enforcement). // Force resize even when dimensions match the server's last known state —
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw. // another device may have changed the PTY size since this client last sent,
await this.sendResize(this.activeSessionId); // and force guarantees a SIGWINCH → Ink redraw at the current device's size.
await this.sendResize(this.activeSessionId, { force: true });
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) { } catch (err) {
@@ -2137,8 +2138,8 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Send resize to a session with minimum dimension enforcement. * Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId * @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options] * @param {{ forceHttp?: boolean, force?: boolean }} [options]
* @returns {Promise<void>} * @returns {Promise<boolean>} Whether dimensions changed from the last send
*/ */
async sendResize(sessionId, options = {}) { async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local // Fit terminal to container before reading dimensions — ensures local
@@ -2167,16 +2168,20 @@ Object.assign(CodemanApp.prototype, {
// Fast path: WebSocket resize // Fast path: WebSocket resize
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) { if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try { try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType })); const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType };
if (options.force) msg.f = true;
this._ws.send(JSON.stringify(msg));
return changed; return changed;
} catch { } catch {
// Fall through to HTTP POST // Fall through to HTTP POST
} }
} }
const body = { ...dims, viewportType };
if (options.force) body.force = true;
await fetch(`/api/sessions/${sessionId}/resize`, { await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dims, viewportType }), body: JSON.stringify(body),
}); });
return changed; return changed;
}, },
+2 -6
View File
@@ -746,14 +746,10 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req) => { app.post('/api/sessions/:id/resize', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body); const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
if (viewportType) { session.resize(cols, rows, { viewportType, force });
session.resize(cols, rows, { viewportType });
} else {
session.resize(cols, rows);
}
return {}; return {};
}); });
+3 -6
View File
@@ -23,7 +23,7 @@
* {"t":"r"} — needs refresh (reload buffer) * {"t":"r"} — needs refresh (reload buffer)
* Client -> Server: * Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste) * {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"z","c":N,"r":N} — resize terminal * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/ */
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
@@ -146,11 +146,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
session.releaseDesktopSizing(sizingToken); session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false; holdsDesktopClaim = false;
} }
if (viewportType) { const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType }); session.resize(msg.c, msg.r, { viewportType, force });
} else {
session.resize(msg.c, msg.r);
}
} }
} catch { } catch {
// Ignore malformed messages // Ignore malformed messages
+2
View File
@@ -186,6 +186,7 @@ export const ResizeSchema = z.object({
cols: z.number().int().min(1).max(500), cols: z.number().int().min(1).max(500),
rows: z.number().int().min(1).max(200), rows: z.number().int().min(1).max(200),
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(), viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
force: z.boolean().optional(),
}); });
/** /**
@@ -384,6 +385,7 @@ export const SettingsUpdateSchema = z
// (client-side), but telemetry COLLECTION is server-side, so the per-device // (client-side), but telemetry COLLECTION is server-side, so the per-device
// toggle signals it out-of-band here rather than via showPlanUsageLimits. // toggle signals it out-of-band here rather than via showPlanUsageLimits.
statusLineTelemetry: z.boolean().optional(), statusLineTelemetry: z.boolean().optional(),
showRedrawButton: z.boolean().optional(),
// Input // Input
gestureControlEnabled: z.boolean().optional(), gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings // Claude CLI settings
+14 -2
View File
@@ -369,7 +369,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40); expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: undefined });
}); });
it('passes viewport type through for resize arbitration', async () => { it('passes viewport type through for resize arbitration', async () => {
@@ -381,7 +381,19 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' }); expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: undefined });
});
it('passes force resize through for redraw requests', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 120, rows: 40, force: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
}); });
it('rejects cols exceeding max (500)', async () => { it('rejects cols exceeding max (500)', async () => {
+19 -4
View File
@@ -285,7 +285,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 })); ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40); expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: false });
}); });
} finally { } finally {
ws.close(); ws.close();
@@ -300,7 +300,22 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' }); expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: false });
});
} finally {
ws.close();
}
});
it('passes force resize through for redraw requests', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40, f: true }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
}); });
} finally { } finally {
ws.close(); ws.close();
@@ -342,7 +357,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 })); ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(1, 1); expect(session.resize).toHaveBeenCalledWith(1, 1, { viewportType: undefined, force: false });
}); });
} finally { } finally {
ws.close(); ws.close();
@@ -357,7 +372,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 })); ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(500, 200); expect(session.resize).toHaveBeenCalledWith(500, 200, { viewportType: undefined, force: false });
}); });
} finally { } finally {
ws.close(); ws.close();
+9
View File
@@ -113,6 +113,15 @@ describe('Session resize arbitration', () => {
expect(resize).toHaveBeenCalledWith(100, 30); expect(resize).toHaveBeenCalledWith(100, 30);
}); });
it('applies forced resizes even when the dimensions did not change', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 120, 40);
session.resize(120, 40, { force: true });
expect(resize).toHaveBeenCalledWith(120, 40);
});
describe('idle-desktop override (whoever is active wins)', () => { describe('idle-desktop override (whoever is active wins)', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();