From 199cdcef2983d870392a7cf7bdacd862ec0555c1 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 22:54:44 +0700 Subject: [PATCH 1/6] fix(panes): scroll the terminal instantly instead of animating the wheel xterm rounds the viewport to whole rows every frame, so a 125ms smooth scroll only spreads the same row steps across more frames, and it applies only when the last wheel events score as a physical wheel. A trackpad gesture flipped between the animated and the instant path mid-scroll, which read as stutter. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- src/terminal/pane.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/terminal/pane.ts b/src/terminal/pane.ts index 321378b5..815153d7 100644 --- a/src/terminal/pane.ts +++ b/src/terminal/pane.ts @@ -220,8 +220,18 @@ export function createPane( // overviewRulerBorder to the background to kill xterm's white separator // hairline, so any border enabled here would be invisible too. overviewRuler: { width: 14 }, - // Smooth wheel scroll (~125ms) feels less jumpy than the default snap. - smoothScrollDuration: 125, + // 0 — xterm's default — on purpose, after 125ms was reported as an + // intermittent stutter. The animation cannot buy smoothness here: the + // viewport rounds every frame back to a whole row + // (`Math.round(scrollTop / cell.height)`) and nothing translates the + // canvas by the remainder, so a duration only spreads the same row steps + // across more frames. It is also conditional — the wheel path picks + // `setScrollPositionSmooth` over `setScrollPositionNow` only when + // `isPhysicalMouseWheel()` scores the last five events as a real wheel, + // and a trackpad crosses that threshold whenever its deltas land on round + // numbers. One gesture flips between the animated path and the instant + // one, which is the stutter. Uniformly instant beats sometimes animated. + smoothScrollDuration: 0, // No minimumContrastRatio on purpose: it rewrites *every* color, so an // agent TUI's deliberately dim grays get pulled up to near-white and the // information hierarchy flattens (SGR 2 `dim` stops reading as dim), on From 414699f28a2c5d39f0a70ae88a60c278fa5bd8a1 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 22:54:44 +0700 Subject: [PATCH 2/6] fix(panes): keep every terminal at or above 24 columns and 6 rows opencode 1.18.31 stops painting for good once its pty is resized to 20 columns or fewer, and Deck panes reach that width easily. fit() now clamps the proposed size to a 24x6 floor, so xterm and the pty stay equal and a narrower pane clips its right edge instead of killing the agent. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- docs/internals/traps.md | 5 +++++ src/terminal/pane.test.ts | 26 ++++++++++++++++++++++++++ src/terminal/pane.ts | 21 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/internals/traps.md b/docs/internals/traps.md index 404b615c..9fbc4a4f 100644 --- a/docs/internals/traps.md +++ b/docs/internals/traps.md @@ -37,6 +37,11 @@ constants that currently switch behaviour off and are meant to be flipped back. - **Props on the element `DesktopChrome` returns are applied on mount and never updated.** The sidebar's live width and collapsed flag are written to `:root` imperatively to sidestep it. +- **A pane's terminal never goes below 24x6, even when its box does.** opencode 1.18.31 stops + painting for good once its pty drops to 20 columns or fewer, also under tmux, and Deck + panes get that narrow easily. [`fit()`](../../src/terminal/pane.ts) clamps the size there, + so the extra columns clip at the pane's right edge. Replacing it with `fitAddon.fit()` + brings the agent death back. ## Hosts and evidence diff --git a/src/terminal/pane.test.ts b/src/terminal/pane.test.ts index 6e07aac2..09398b44 100644 --- a/src/terminal/pane.test.ts +++ b/src/terminal/pane.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act } from "preact/test-utils"; import { tabViews } from "./tabs-store"; +import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS, type Settings } from "../settings/settings-schema"; @@ -138,3 +139,28 @@ describe("Claude header input routing", () => { } }); }); + +describe("Pane column floor", () => { + it("never resizes the terminal below 24x6, however small the box measures", () => { + const propose = vi + .spyOn(FitAddon.prototype, "proposeDimensions") + .mockReturnValue({ cols: 18, rows: 3 }); + const resize = vi.spyOn(Terminal.prototype, "resize"); + const pane = createPane(30, DEFAULT_SETTINGS as Settings, silentEvents, { + cols: 101, + rows: 16, + }); + try { + pane.fit(); + expect(resize).toHaveBeenLastCalledWith(24, 6); + expect(pane.cols).toBe(24); + propose.mockReturnValue({ cols: 90, rows: 30 }); + pane.fit(); + expect(resize).toHaveBeenLastCalledWith(90, 30); + } finally { + pane.dispose(); + propose.mockRestore(); + resize.mockRestore(); + } + }); +}); diff --git a/src/terminal/pane.ts b/src/terminal/pane.ts index 815153d7..a3b849e2 100644 --- a/src/terminal/pane.ts +++ b/src/terminal/pane.ts @@ -453,9 +453,28 @@ export function createPane( fit(); } + // A floor for the terminal size, below whatever the pane box measures. + // opencode 1.18.31 stops painting for good once its pty is resized to 20 + // columns or fewer (measured 2026-09-21: 21–25 repaint, 18–20 go silent, + // also under tmux), and Deck panes reach that width easily. Clamping here, + // where every dock, split, divider drag, window resize and restore lands, + // keeps xterm and the pty equal; a narrower pane clips its right edge + // through `.pane { overflow: hidden }` instead of wrapping. + const MIN_TERMINAL_COLS = 24; + const MIN_TERMINAL_ROWS = 6; + function fit(): void { try { - fitAddon.fit(); + // Not `fitAddon.fit()`: it applies the measured size unclamped. Its + // private `_renderService.clear()` is skipped too; `term.resize` already + // triggers a full refresh. + const proposed = fitAddon.proposeDimensions(); + if (!proposed || Number.isNaN(proposed.cols) || Number.isNaN(proposed.rows)) return; + const cols = Math.max(MIN_TERMINAL_COLS, proposed.cols); + const rows = Math.max(MIN_TERMINAL_ROWS, proposed.rows); + if (cols !== term.cols || rows !== term.rows) { + term.resize(cols, rows); + } } catch { // Element not in DOM yet or zero-sized — skip, next fit will succeed } From 58a457e58e5de570e739c3f1d61199957daf766f Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 22:54:50 +0700 Subject: [PATCH 3/6] fix(limits): share the Claude limit collector across Deck installs Claude has one user-level status line, but the collector lived in one install's userData and refused a wrapper written by another install. A dev build, an older release or a second copy then could not read Claude limits. The collector now lives under the shared app data root, and the last install to start re-wraps any Deck wrapper while keeping the user's own command. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- electron/agent-limits/claude-reader.test.ts | 15 ++++++++ electron/agent-limits/claude-reader.ts | 41 +++++++++++++++++---- electron/agent-limits/service.test.ts | 6 +-- electron/agent-limits/service.ts | 10 ++++- electron/ipc/register-services.ts | 2 +- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/electron/agent-limits/claude-reader.test.ts b/electron/agent-limits/claude-reader.test.ts index 0bcc5e1c..20238d08 100644 --- a/electron/agent-limits/claude-reader.test.ts +++ b/electron/agent-limits/claude-reader.test.ts @@ -112,6 +112,21 @@ describe.skipIf(process.platform === "win32")("Claude status-line collector", () await fs.unlink(path.join(options().directory, "claude-statusline.cjs")); expect(await run((await document()).statusLine.command, "still works")).toBe("still works"); }); + it("takes over another Deck install's wrapper and keeps the user's own command", async () => { + const original = { type: "command", command: `cat; printf %s "it's mine"`, refreshInterval: 5 }; + await fs.writeFile(options().settingsPath, JSON.stringify({ statusLine: original })); + const other = { ...options(), directory: path.join(root, "other-install") }; + await installClaudeLimitCollector(other); + await fs.rm(other.directory, { recursive: true }); + await installClaudeLimitCollector(options()); + const command = (await document()).statusLine.command; + expect(command).toContain(path.join(options().directory, "claude-statusline.cjs")); + expect(command).not.toContain("other-install"); + expect(await run(command, "passes through")).toBe("passes throughit's mine"); + await installClaudeLimitCollector(options()); + await restoreClaudeLimitCollector(options()); + expect((await document()).statusLine).toEqual(original); + }); it("refuses malformed settings without overwriting them", async () => { await fs.writeFile(options().settingsPath, "broken json"); await expect(installClaudeLimitCollector(options())).rejects.toThrow(SyntaxError); diff --git a/electron/agent-limits/claude-reader.ts b/electron/agent-limits/claude-reader.ts index 2665bdc1..487a9a06 100644 --- a/electron/agent-limits/claude-reader.ts +++ b/electron/agent-limits/claude-reader.ts @@ -34,7 +34,31 @@ async function optionalJson(file: string): Promise { } } -/** Wrap exactly the current command and retain all other status-line options/settings. */ +// The tail every wrapper below has carried since the first release: the user's +// own command, shell-quoted, or `:` when there was none. +const WRAPPER_FALLBACK = /; else sh -c ((?:'[^']*'|\\')+); fi$/; + +/** + * The command a Deck wrapper stands in front of: `null` when the user had no + * status line, `undefined` when `command` is not a Deck wrapper. Read from + * the wrapper itself rather than a manifest because the wrapper may belong to + * another Deck install (a dev build, an older release keyed to its own + * userData) whose manifest this one cannot find. + */ +function wrappedCommand(command: unknown): string | null | undefined { + if (typeof command !== "string" || !command.startsWith("if [ -x ") || !command.includes(SCRIPT)) + return undefined; + const quoted = WRAPPER_FALLBACK.exec(command)?.[1]; + if (quoted === undefined) return undefined; + const unquoted = quoted.replace(/'([^']*)'|\\'/g, (_match, inner?: string) => inner ?? "'"); + return unquoted === ":" ? null : unquoted; +} + +/** + * Wrap the user's command and retain all other status-line options/settings. + * The last Deck install to start owns the wrapper: it re-wraps any Deck + * wrapper with its own executable, and every install reads the same captures. + */ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): Promise { const script = path.join(options.directory, SCRIPT); const manifestFile = path.join(options.directory, MANIFEST); @@ -42,10 +66,14 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): await updateClaudeUserSettings( options.settingsPath ?? claudeUserSettingsPath(), async (document) => { - const saved = limitRecord(await optionalJson(manifestFile)); const current = limitRecord(document.statusLine); - const owned = saved && current?.command === saved.installedCommand; - const original = owned ? saved.original : (document.statusLine ?? null); + const wrapped = wrappedCommand(current?.command); + const original = + wrapped === undefined + ? (document.statusLine ?? null) + : wrapped === null + ? null + : { ...current, command: wrapped }; const status = limitRecord(original); if ( original !== null && @@ -53,9 +81,6 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): ) { throw new Error("Unsupported Claude status line; settings were not changed."); } - if (!owned && typeof current?.command === "string" && current.command.includes(SCRIPT)) { - throw new Error("Another Deck installation owns this Claude status line."); - } const executable = shellQuote(options.executable); const run = `ELECTRON_RUN_AS_NODE=1 ${executable} ${shellQuote(script)} ${shellQuote(String(status?.command ?? ""))}`; const fallback = `sh -c ${shellQuote(String(status?.command ?? ":"))}`; @@ -67,7 +92,7 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): return { ...document, statusLine: { - ...((owned ? current : status) ?? {}), + ...(current ?? {}), type: "command", command: installedCommand, }, diff --git a/electron/agent-limits/service.test.ts b/electron/agent-limits/service.test.ts index 5d7589e7..c2c53ba8 100644 --- a/electron/agent-limits/service.test.ts +++ b/electron/agent-limits/service.test.ts @@ -9,7 +9,7 @@ describe("shared agent limit service", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const secret = "synthetic-sensitive-setting"; const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", readCodex: async () => absentLimits("codex"), connectClaude: async () => { @@ -26,7 +26,7 @@ describe("shared agent limit service", () => { const readCodex = vi.fn(async () => absentLimits("codex")); const connectClaude = vi.fn(async () => undefined); const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", now: () => now, readCodex, @@ -46,7 +46,7 @@ describe("shared agent limit service", () => { it("fails closed on a failed account refresh and leaves the other provider readable", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", readCodex: async () => { throw new Error("signed out"); diff --git a/electron/agent-limits/service.ts b/electron/agent-limits/service.ts index 41ca59d5..592c32b6 100644 --- a/electron/agent-limits/service.ts +++ b/electron/agent-limits/service.ts @@ -10,7 +10,8 @@ import { installClaudeLimitCollector, readClaudeLimits } from "./claude-reader"; import { readCodexLimits } from "./codex-reader"; interface LimitsServiceOptions { - readonly userData: string; + /** The per-user app data root (`app.getPath("appData")`), not one install's userData. */ + readonly appData: string; readonly executable: string; readonly now?: () => number; readonly readCodex?: (signal: AbortSignal) => Promise; @@ -18,11 +19,16 @@ interface LimitsServiceOptions { readonly connectClaude?: () => Promise; } +const SHARED_LIMITS_DIRECTORY = path.join("SpaceVibe", "agent-limits"); + /** One Codex request per minute across windows; Claude consumes local status-line reports. */ export function createAgentLimitsService(options: LimitsServiceOptions) { const now = options.now ?? Date.now; const controller = new AbortController(); - const directory = path.join(options.userData, "agent-limits"); + // Shared by every Deck install on the account (release, dev build, a second + // copy): Claude has one user-level status line, so a collector keyed to one + // install's userData left every other install unable to read limits. + const directory = path.join(options.appData, SHARED_LIMITS_DIRECTORY); let codex = absentLimits("codex"); let lastCodexAttempt = -Infinity; let codexFlight: Promise | null = null; diff --git a/electron/ipc/register-services.ts b/electron/ipc/register-services.ts index 697ba9c0..1168cf19 100644 --- a/electron/ipc/register-services.ts +++ b/electron/ipc/register-services.ts @@ -33,7 +33,7 @@ export interface RegisterServicesDeps { export function registerServices(deps: RegisterServicesDeps): void { const limits = createAgentLimitsService({ - userData: app.getPath("userData"), + appData: app.getPath("appData"), executable: process.execPath, }); ipcMain.handle(CHANNELS.agentLimitsSnapshot, () => limits.snapshot()); From 70b8eb16be4d3703960b434d0511346984a03c76 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 22:55:02 +0700 Subject: [PATCH 4/6] feat(rail): remove the Needs me count above the sidebar projects The owner removed the aggregate attention button; per-agent status marks and the Board bar's Needs me filter still answer who is waiting. DL-27.26 now records the removal, and its checkout-creation note describes the launch page that replaced the actions menu on Electron. The static count styles stay for the parked 2026-09-18 gallery record. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- docs/DESIGN-LANGUAGE.md | 59 +++++++++-------------------------- src/styles/04a-agent-rail.css | 24 ++------------ src/ui/agent-rail.tsx | 44 +------------------------- 3 files changed, 18 insertions(+), 109 deletions(-) diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md index ee7970cf..a337b3e3 100644 --- a/docs/DESIGN-LANGUAGE.md +++ b/docs/DESIGN-LANGUAGE.md @@ -2924,50 +2924,21 @@ a 1.5s effect. The ping is the inset hairline DL-1.3 explicitly permits. ([head styles](../src/styles/04c-rail-worktree-card.css)); the bare row keeps 132px. -- **DL-27.26** **One `Needs me N` line above the clusters (2026-09-18, design - review L5).** The rail owns the question "which agent needs me" and answers - it once, at the top: a count of the panes in `asked` or `failed` across every - card, folded or not, drawn only while it is above zero. Pressing it runs the - same preflight as ⌘⇧A (`focus-next-attention`) and lands on the loudest - pane. It is a flat pill in the Board bar's chip vocabulary (DL-34.11): a - `--status-unread` wash inside DL-1.3's inset hairline, the count at - `--text-primary` 620 and the label at `--text-muted`, sharing the cluster - header's 7px inset. Strip glyphs keep their corner marks (DL-27.3) and never - carry a count; the Board bar's own `Needs me` filter (DL-34.11) is the same - figure on the other surface. Rendered as a still `` where nothing - wires the focus (DL-19.7). [Rail styles](../src/styles/04a-agent-rail.css), - [`AgentRail`](../src/ui/agent-rail.tsx). - - **Amended 2026-09-02 (owner, `openspec/changes/rail-create-consolidation`): - every checkout carries exactly ONE create control, and it opens the agent - list.** The open card's `New agent` row and the bare row of a checkout with - nothing open had spawned a plain SHELL through `onNewTabIn` — a process - started without a word about its agent or its place, under a label that said - "agent". Both open the checkout's actions menu now, anchored to the card or - row, exactly as the closed strip's `+` and a right-click do - ([`useActionsMenu`](../src/ui/worktree-card.tsx) `current` is the one state - all three shapes share); a press starts nothing, and selecting an agent - starts it. Agent rows show only the agent name, without a `Run` prefix. A folder git does not know (flat entries) ends with the same - `New agent` row, and its menu drops every git-backed row. A REMEMBERED - project prints its remembered checkouts as rowless groups - ([`rememberedWorktrees`](../src/ui/agent-rail-model.ts) `current`) so their - bare rows are its way back in, since the header's `+` (DL-27.18, retired) is - gone. `Open shell` opens a new shell tab even in a busy checkout; - `New split here` opens a pane beside the existing tab and materializes a tab - when the checkout has none. Both use the checkout named by the menu - ([menu callbacks](../src/ui/app.tsx), [tab creation](../src/terminal/tab-manager.ts)). - **The menu's heading returns for ONE placement:** raised by `⌘T` with no card - beside it, the same `CardActionsMenu` stands free under the stage strip - (DL-13.7, amended) and prints the composed destination - ([`subjectWhere`](../src/ui/agent-rail-card-model.ts) `current` — `whereOf`'s - own words) as one line, `.asr-act__where`, in title ink on the title rung; - never the `Actions for` / `Runs in` pair that came off on 2026-08-30. That - placement alone also carries `Open another project…`, because with the tab - strip's `+` gone, top-tab mode and a hidden sidebar have no other route to - the Open board. The menu reads a - [`MenuSubject`](../src/ui/agent-rail-card-model.ts) `current` rather than a - card's group, so the chord and the card build it from different inputs and - name one checkout with one set of words. +- **DL-27.26** **No aggregate attention button in the sidebar (2026-09-21).** + The owner removed the `Needs me N` line above the project clusters from + [`AgentRail`](../src/ui/agent-rail.tsx). Per-agent status indicators and the + [Board bar](../src/ui/agent-board-bar.tsx) remain available. + + **Checkout creation (2026-09-18, DECK-27):** the open `New agent` row, + collapsed strip `+`, bare checkout and flat folder row open the compact + full-page agent launcher on Electron. Opening creates nothing; `Run` splits + beside its captured target or creates a first pane when none exists. + [Create controls](../src/ui/worktree-card.tsx) omit menu ARIA on this route. + Right-click retains the checkout actions menu and its new-tab agent launches, + `Open shell` and `New split here`. Tauri retains the menu fallback. + Cmd/Ctrl+T uses the page when it has workspace context and the Open board + otherwise ([entry routing](../src/ui/app.tsx)). The legacy free-standing menu + still states its destination through [MenuSubject](../src/ui/agent-rail-card-model.ts). ## 28. The rail's action footer diff --git a/src/styles/04a-agent-rail.css b/src/styles/04a-agent-rail.css index 57cc1462..7845682c 100644 --- a/src/styles/04a-agent-rail.css +++ b/src/styles/04a-agent-rail.css @@ -969,15 +969,8 @@ pointer-events: none; } -/* DL-27.26 (2026-09-18): the `Needs me N` line above the clusters. One count - of the panes in `asked` or `failed`, drawn only while it is above zero, and - pressing it runs ⌘⇧A. It shares the cluster header's 7px inset so the chip - sits on the checkout dots' edge, and takes the same flat pill vocabulary as - the Board bar's filter chip (DL-34.11): a `--tone` wash inside DL-1.3's - inset hairline, no shadow, no motion. The count is the loud word — primary - text at 620 — and the label stays muted, so the line reads as a figure - with a caption rather than a button shouting. `` when nothing wires - the press (the gallery), ` - )} - - )}
{view.stream.map((group) => { const collapsed = collapsedGroupKeys.value.has(group.key); From 1f3f6d93582fd07b972fb67b1291c84b8ea69b17 Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 22:55:18 +0700 Subject: [PATCH 5/6] chore(release): cut 2.1.0 Bump the version and write the `## 2.1.0` section the promote job publishes. The Quick Launch tiling and plain-folder card entries had landed under the frozen `## 2.0.0` heading after that tag; they move here, and `## 2.0.0` is restored to the text the tag shipped. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++---------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e799a04d..a28d1bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ User-facing release notes. The release workflow's `promote` job publishes the platform-limitations header), so each section is written for users, reviewed in the release PR, and frozen at the tag — never an auto-generated commit list. +## 2.1.0 + +This update tiles quick-launched agents evenly, gives plain folders a sidebar +card, and fixes agents that stopped drawing in narrow panes. + +### Sidebar + +- **Plain folders get a card like a repository.** A workspace that is not a git + repository now shows the same [sidebar card](src/ui/worktree-card.tsx) as a + repository checkout, labelled `Folder`, instead of loose agent rows. A folder + opened inside a larger repository shows its own name, not the repository's. + +- Removed the `Needs me` count button above the + [sidebar project list](src/ui/agent-rail.tsx). Each agent still shows its own + status, and the Agent Board's Needs me filter is unchanged. + +- **Claude Code limits show in every copy of Deck.** The + [limit collector](electron/agent-limits/claude-reader.ts) is now shared, so a + second Deck install no longer shows a dash while another one owns Claude's + status line. Your own status line command is still preserved. + +### Agents + +- **Quick Launch tiles instead of stacking columns.** A launched agent now splits the + [roomiest pane](src/lib/pane-tiling.ts) of the tab along its longer side, so the second, + third and fourth agent fill the tab evenly instead of halving one pane into ever + narrower strips. A divider you dragged yourself still decides where the next pane lands. + +- **Agents keep drawing in narrow panes.** A terminal never shrinks below 24 + columns and 6 rows; a narrower pane [clips its right edge](src/terminal/pane.ts) + instead. OpenCode stopped drawing for good once its pane reached 20 columns + or fewer, even after the pane grew back. + +- **Trackpad scrolling in terminals no longer stutters.** Terminal scrolling + is now instant, instead of switching between animated and instant scrolling + in the middle of a gesture. + ## 2.0.0 ### Feedback @@ -24,11 +61,6 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Sidebar -- **Plain folders get a card like a repository.** A workspace that is not a git - repository now shows the same [sidebar card](src/ui/worktree-card.tsx) as a - repository checkout, labelled `Folder`, instead of loose agent rows. A folder - opened inside a larger repository shows its own name, not the repository's. - - **Workspace favicons in the sidebar.** [Project headers](src/ui/agent-rail.tsx) show the workspace favicon when available, falling back to the folder icon when the image is missing or cannot be displayed. @@ -47,11 +79,6 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Agents -- **Quick Launch tiles instead of stacking columns.** A launched agent now splits the - [roomiest pane](src/lib/pane-tiling.ts) of the tab along its longer side, so the second, - third and fourth agent fill the tab evenly instead of halving one pane into ever - narrower strips. A divider you dragged yourself still decides where the next pane lands. - - **Removed yellow lines above terminal panes.** The [pane overlays](src/styles/06-stage-panes.css) no longer animate while agents work or when selecting an agent from the sidebar. Agent status indicators and pane navigation keep their existing behavior. diff --git a/package-lock.json b/package-lock.json index b93cf230..b3df90de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spacevibe-deck", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spacevibe-deck", - "version": "2.0.0", + "version": "2.1.0", "hasInstallScript": true, "dependencies": { "@sentry/electron": "^7.18.0", diff --git a/package.json b/package.json index 1548613f..32226af6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spacevibe-deck", "private": true, - "version": "2.0.0", + "version": "2.1.0", "type": "module", "scripts": { "generate:menu": "tsx scripts/generate-menu.ts", From c7fa83d28064652e2847f69aab672564d8ee1a0d Mon Sep 17 00:00:00 2001 From: mxrsv Date: Wed, 23 Sep 2026 23:52:17 +0700 Subject: [PATCH 6/6] fix(panes): let terminal rows follow the pane box again The 6-row floor added with the column floor had no measurement behind it. In a pane shorter than six rows it sized the terminal taller than its box, and `.pane__term` clipped the bottom rows, where agents keep their prompt. Only the measured 24-column floor stays. Raised by CodeRabbit on #36 and confirmed against the code path by a Codex review. Claude-Session: https://claude.ai/code/session_01B3NdvirX6B8GjunZFBWTBt --- CHANGELOG.md | 2 +- docs/internals/traps.md | 7 ++++--- src/terminal/pane.test.ts | 7 +++++-- src/terminal/pane.ts | 9 +++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a28d1bca..a3bdac0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ card, and fixes agents that stopped drawing in narrow panes. narrower strips. A divider you dragged yourself still decides where the next pane lands. - **Agents keep drawing in narrow panes.** A terminal never shrinks below 24 - columns and 6 rows; a narrower pane [clips its right edge](src/terminal/pane.ts) + columns; a narrower pane [clips its right edge](src/terminal/pane.ts) instead. OpenCode stopped drawing for good once its pane reached 20 columns or fewer, even after the pane grew back. diff --git a/docs/internals/traps.md b/docs/internals/traps.md index 9fbc4a4f..8c2f0442 100644 --- a/docs/internals/traps.md +++ b/docs/internals/traps.md @@ -37,11 +37,12 @@ constants that currently switch behaviour off and are meant to be flipped back. - **Props on the element `DesktopChrome` returns are applied on mount and never updated.** The sidebar's live width and collapsed flag are written to `:root` imperatively to sidestep it. -- **A pane's terminal never goes below 24x6, even when its box does.** opencode 1.18.31 stops +- **A pane's terminal never goes below 24 columns, even when its box does.** opencode 1.18.31 stops painting for good once its pty drops to 20 columns or fewer, also under tmux, and Deck panes get that narrow easily. [`fit()`](../../src/terminal/pane.ts) clamps the size there, - so the extra columns clip at the pane's right edge. Replacing it with `fitAddon.fit()` - brings the agent death back. + so the extra columns clip at the pane's right edge. Rows follow the box: a row floor would + clip the bottom, where agents keep their prompt. Replacing it with `fitAddon.fit()` brings + the agent death back. ## Hosts and evidence diff --git a/src/terminal/pane.test.ts b/src/terminal/pane.test.ts index 09398b44..49714b89 100644 --- a/src/terminal/pane.test.ts +++ b/src/terminal/pane.test.ts @@ -141,7 +141,7 @@ describe("Claude header input routing", () => { }); describe("Pane column floor", () => { - it("never resizes the terminal below 24x6, however small the box measures", () => { + it("never resizes the terminal below 24 columns, however narrow the box measures", () => { const propose = vi .spyOn(FitAddon.prototype, "proposeDimensions") .mockReturnValue({ cols: 18, rows: 3 }); @@ -152,8 +152,11 @@ describe("Pane column floor", () => { }); try { pane.fit(); - expect(resize).toHaveBeenLastCalledWith(24, 6); + expect(resize).toHaveBeenLastCalledWith(24, 3); expect(pane.cols).toBe(24); + propose.mockReturnValue({ cols: 18, rows: 1 }); + pane.fit(); + expect(resize).toHaveBeenLastCalledWith(24, 1); propose.mockReturnValue({ cols: 90, rows: 30 }); pane.fit(); expect(resize).toHaveBeenLastCalledWith(90, 30); diff --git a/src/terminal/pane.ts b/src/terminal/pane.ts index a3b849e2..ace5606a 100644 --- a/src/terminal/pane.ts +++ b/src/terminal/pane.ts @@ -453,15 +453,16 @@ export function createPane( fit(); } - // A floor for the terminal size, below whatever the pane box measures. + // A floor for the terminal width, below whatever the pane box measures. // opencode 1.18.31 stops painting for good once its pty is resized to 20 // columns or fewer (measured 2026-09-21: 21–25 repaint, 18–20 go silent, // also under tmux), and Deck panes reach that width easily. Clamping here, // where every dock, split, divider drag, window resize and restore lands, // keeps xterm and the pty equal; a narrower pane clips its right edge - // through `.pane { overflow: hidden }` instead of wrapping. + // through `.pane { overflow: hidden }` instead of wrapping. Rows get no + // floor on purpose: nothing measured one, and a floor taller than the box + // clips the bottom rows, where an agent keeps its live prompt. const MIN_TERMINAL_COLS = 24; - const MIN_TERMINAL_ROWS = 6; function fit(): void { try { @@ -471,7 +472,7 @@ export function createPane( const proposed = fitAddon.proposeDimensions(); if (!proposed || Number.isNaN(proposed.cols) || Number.isNaN(proposed.rows)) return; const cols = Math.max(MIN_TERMINAL_COLS, proposed.cols); - const rows = Math.max(MIN_TERMINAL_ROWS, proposed.rows); + const rows = proposed.rows; if (cols !== term.cols || rows !== term.rows) { term.resize(cols, rows); }