Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
950 changes: 950 additions & 0 deletions docs/plans/2026-07-30-deck-render-refinements.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ const TERMINAL_RENDERER_VALUES = ['auto', 'webgl', 'canvas'] as const
const DEFAULT_NEW_PANE_VALUES = ['ask', 'shell', 'browser', 'editor'] as const
const TAB_ATTENTION_STYLE_VALUES = ['highlight', 'pulse', 'darken', 'none'] as const
export const DECK_TILE_STYLE_VALUES = ['status-icons', 'terminal-previews'] as const
/** Key layout for the Stream Deck. 'auto' resolves per device: reversed
* ("newest first", pager pinned top-left) on the smallest decks
* (keyCount <= 6, e.g. the 6-key Mini), status-sorted on larger decks.
* 'newest-first' = strictly reverse tab-bar order: newest tab first while
* tabs are unreordered; after a manual reorder (or cross-device order sync)
* the deck mirrors the reversed tab bar. Deliberate — see plan naming note. */
export const DECK_KEY_LAYOUT_VALUES = ['auto', 'newest-first', 'status-sorted'] as const
const ATTENTION_DISMISS_VALUES = ['click', 'type'] as const
const SESSION_OPEN_MODE_VALUES = ['tab', 'split'] as const
const SIDEBAR_SORT_MODE_VALUES = ['recency', 'recency-pinned', 'activity', 'project'] as const
Expand Down Expand Up @@ -97,6 +104,7 @@ export type TerminalRendererMode = (typeof TERMINAL_RENDERER_VALUES)[number]
export type DefaultNewPane = (typeof DEFAULT_NEW_PANE_VALUES)[number]
export type TabAttentionStyle = (typeof TAB_ATTENTION_STYLE_VALUES)[number]
export type DeckTileStyle = (typeof DECK_TILE_STYLE_VALUES)[number]
export type DeckKeyLayout = (typeof DECK_KEY_LAYOUT_VALUES)[number]
export type AttentionDismiss = (typeof ATTENTION_DISMISS_VALUES)[number]
export type SessionOpenMode = (typeof SESSION_OPEN_MODE_VALUES)[number]
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODE_VALUES)[number]
Expand Down Expand Up @@ -228,6 +236,7 @@ export type LocalSettings = {
idleBrightness: number
idleTimeoutSeconds: number
tileStyle: DeckTileStyle
keyLayout: DeckKeyLayout
}
}

Expand Down Expand Up @@ -264,6 +273,7 @@ const TerminalRendererSchema = z.enum(TERMINAL_RENDERER_VALUES)
const DefaultNewPaneSchema = z.enum(DEFAULT_NEW_PANE_VALUES)
const TabAttentionStyleSchema = z.enum(TAB_ATTENTION_STYLE_VALUES)
const DeckTileStyleSchema = z.enum(DECK_TILE_STYLE_VALUES)
const DeckKeyLayoutSchema = z.enum(DECK_KEY_LAYOUT_VALUES)
const AttentionDismissSchema = z.enum(ATTENTION_DISMISS_VALUES)
const SessionOpenModeSchema = z.enum(SESSION_OPEN_MODE_VALUES)
const ExternalEditorSchema = z.enum(EXTERNAL_EDITOR_VALUES)
Expand Down Expand Up @@ -649,6 +659,9 @@ function normalizeExtractedLocalSeed(patch: Record<string, unknown>): LocalSetti
if (DeckTileStyleSchema.safeParse(patch.streamDeck.tileStyle).success) {
streamDeck.tileStyle = patch.streamDeck.tileStyle as DeckTileStyle
}
if (DeckKeyLayoutSchema.safeParse(patch.streamDeck.keyLayout).success) {
streamDeck.keyLayout = patch.streamDeck.keyLayout as DeckKeyLayout
}
if (Object.keys(streamDeck).length > 0) {
normalized.streamDeck = streamDeck
}
Expand Down Expand Up @@ -905,6 +918,7 @@ export const defaultLocalSettings: LocalSettings = {
idleBrightness: 10,
idleTimeoutSeconds: 300,
tileStyle: 'status-icons',
keyLayout: 'auto',
},
}

Expand Down Expand Up @@ -1460,7 +1474,7 @@ export function extractLegacyLocalSettingsSeed(
maybeAssignNested(
patch,
'streamDeck',
pickKeys(raw.streamDeck, ['enabled', 'brightness', 'idleBrightness', 'idleTimeoutSeconds', 'tileStyle']),
pickKeys(raw.streamDeck, ['enabled', 'brightness', 'idleBrightness', 'idleTimeoutSeconds', 'tileStyle', 'keyLayout']),
)
}

Expand Down
21 changes: 20 additions & 1 deletion src/components/settings/StreamDeckSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { setVirtualDeckOpen, type DeckSliceState } from '@/store/deckSlice'
import { requestDeckConnect } from '@/deck/deck-manager'
import { isElectronClient, isWebHidSupported } from '@/lib/webhid-support'
import type { SettingsSectionProps } from './settings-types'
import type { DeckTileStyle } from '../../../shared/settings'
import type { DeckTileStyle, DeckKeyLayout } from '../../../shared/settings'
import {
SettingsSection,
SettingsRow,
Expand Down Expand Up @@ -94,6 +94,25 @@ export default function StreamDeckSettings({
/>
</SettingsRow>

<SettingsRow
label="Key layout"
description="Auto uses Newest first on small decks (6 keys or fewer) and Status sorted on larger ones. Newest first pins the pager top-left and mirrors the tab bar in reverse — newest tabs first — in stable positions. Status sorted orders keys by attention, with a pager only on overflow."
>
<SegmentedControl
value={streamDeck.keyLayout}
aria-label="Key layout"
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'newest-first', label: 'Newest first' },
{ value: 'status-sorted', label: 'Status sorted' },
]}
onChange={(v: string) => {
const keyLayout = v as DeckKeyLayout
applyLocalSetting({ streamDeck: { keyLayout } })
}}
/>
</SettingsRow>

{connectAvailable && (
<SettingsRow label="Connection" description={deckStatusText(deck)}>
<button
Expand Down
38 changes: 25 additions & 13 deletions src/deck/deck-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@

import type { DeckCapabilities, DeckDevice, DeckInputEvent } from './deck-device'
import type { DeckStore } from './deck-actions'
import type { KeySpec } from './frame'
import type { DeckModel } from './deck-selectors'
import type { DeckArrangement, KeySpec, LayoutPlan } from './frame'
import type { DeckModel, DeckTab } from './deck-selectors'
import type { RootState } from '@/store/store'
import type { DeckTileStyle } from '@shared/settings'
import { ACTION_KEYS, buildFrame, clampPage, pageCount, planLayout, visibleTabs } from './frame'
import { ACTION_KEYS, arrangeTabs, buildFrame, clampPage, pageCount, planLayout, resolveArrangement, visibleTabs } from './frame'
import { findApproveTarget, findStopTarget, panesForTab, selectDeckModel } from './deck-selectors'
import { executeDeckStop, focusTabFromDeck, sendDeckApproval } from './deck-actions'
import { dismissTabGreen } from '@/store/turnCompletionAttention'
Expand Down Expand Up @@ -144,7 +144,7 @@ export class DeckController {
const model = pre?.model ?? selectDeckModel(state)
this.lastModelJson = pre?.modelJson ?? JSON.stringify(model)
const caps = this.device.capabilities
const plan = planLayout(caps, model.tabs.length)
const { plan } = this.layout(model)
this.lastTabsPerPage = plan.tabsPerPage
const pages = pageCount(model.tabs.length, plan.tabsPerPage)
this.page = clampPage(this.page, pages)
Expand Down Expand Up @@ -235,7 +235,7 @@ export class DeckController {
const model = selectDeckModel(state)
const modelJson = JSON.stringify(model)
if (modelJson === this.lastModelJson) return
const plan = planLayout(this.device.capabilities, model.tabs.length)
const { plan } = this.layout(model)
if (this.lastTabsPerPage !== null && plan.tabsPerPage !== this.lastTabsPerPage) this.page = 1
this.page = clampPage(this.page, pageCount(model.tabs.length, plan.tabsPerPage))
this.repaint({ model, modelJson })
Expand Down Expand Up @@ -267,15 +267,27 @@ export class DeckController {
}
}

/** Single source of arrangement truth for this device: plan + ordered tabs.
* buildFrame derives the same pair internally from model.keyLayout + caps,
* so painting and press targeting stay mirror images. */
private layout(model: DeckModel): { arrangement: DeckArrangement; plan: LayoutPlan; tabs: DeckTab[] } {
const arrangement = resolveArrangement(model.keyLayout, this.device.capabilities.keyCount)
return {
arrangement,
plan: planLayout(this.device.capabilities, model.tabs.length, arrangement),
tabs: arrangeTabs(model.tabs, arrangement),
}
}

/** What this key DISPLAYS right now - captured at press-down so re-sorts can't retarget a press. */
private resolveKeyTarget(keyIndex: number): PressTarget {
const model = selectDeckModel(this.store.getState())
const plan = planLayout(this.device.capabilities, model.tabs.length)
const { plan, tabs } = this.layout(model)
if (plan.pagerKey !== null && keyIndex === plan.pagerKey) return { kind: 'pager' }
const slot = plan.tabSlots.indexOf(keyIndex)
if (slot === -1) return { kind: 'none' }
const pages = pageCount(model.tabs.length, plan.tabsPerPage)
const tab = visibleTabs(model.tabs, clampPage(this.page, pages), plan.tabsPerPage)[slot]
const tab = visibleTabs(tabs, clampPage(this.page, pages), plan.tabsPerPage)[slot]
return tab ? { kind: 'tab', tabId: tab.id } : { kind: 'none' }
}

Expand All @@ -291,7 +303,7 @@ export class DeckController {
const duration = this.now() - press.at
if (press.target.kind === 'pager') {
const model = selectDeckModel(this.store.getState())
const plan = planLayout(this.device.capabilities, model.tabs.length)
const { plan } = this.layout(model)
const pages = pageCount(model.tabs.length, plan.tabsPerPage)
this.page = this.page >= pages ? 1 : this.page + 1
this.repaint()
Expand Down Expand Up @@ -355,14 +367,14 @@ export class DeckController {
this.noteActivity()
const state = this.store.getState()
const model = selectDeckModel(state)
const plan = planLayout(this.device.capabilities, model.tabs.length)
const { plan, tabs } = this.layout(model)
if (!plan.useDials) return
if (dialIndex === 0) {
const n = model.tabs.length
const n = tabs.length
if (n === 0) return
const idx = model.tabs.findIndex((t) => t.id === model.activeTabId)
const idx = tabs.findIndex((t) => t.id === model.activeTabId)
const next = ((((idx === -1 ? 0 : idx) + ticks) % n) + n) % n
focusTabFromDeck(this.store, model.tabs[next].id)
focusTabFromDeck(this.store, tabs[next].id)
return
}
if (dialIndex === 1) {
Expand All @@ -375,7 +387,7 @@ export class DeckController {
this.noteActivity()
const state = this.store.getState()
const model = selectDeckModel(state)
const plan = planLayout(this.device.capabilities, model.tabs.length)
const { plan } = this.layout(model)
if (!plan.useDials) return
if (dialIndex === 0) {
if (model.activeTabId) focusTabFromDeck(this.store, model.activeTabId)
Expand Down
17 changes: 12 additions & 5 deletions src/deck/deck-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route'
import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-icon'
import { hueFromString } from '@/components/icons/RepoIcon'
import { makeFreshAgentSessionKey } from '@shared/fresh-agent'
import type { DeckTileStyle } from '@shared/settings'
import type { DeckKeyLayout, DeckTileStyle } from '@shared/settings'
import { isNonShellMode } from '@/lib/coding-cli-utils'
import { getTabDisplayTitle } from '@/lib/tab-title'

export type TilePaneTint = 'blue' | 'green' | 'amber' | 'red' | 'mutedDim' | 'muted'
export type TilePaneIcon = { provider: string; tint: TilePaneTint }
Expand All @@ -25,10 +26,12 @@ export type DeckTab = {
fill: TileFill
dot: TileDot
priority: number
/** Position in the tab bar (index into state.tabs.tabs), surviving any model-level sort. */
tabIndex: number
repoIcons: TileRepoIcon[]
paneIcons: TilePaneIcon[]
}
export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle }
export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle; keyLayout: DeckKeyLayout }

function activityInputs(state: RootState) {
return {
Expand Down Expand Up @@ -200,19 +203,23 @@ export function getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags {
export function selectDeckModel(state: RootState): DeckModel {
const activeTabId = state.tabs.activeTabId
const tileStyle = state.settings.settings.streamDeck.tileStyle
const tabs = state.tabs.tabs.map((tab) => {
const tabs = state.tabs.tabs.map((tab, index) => {
const active = tab.id === activeTabId
const flags = getTabStatusFlags(state, tab)
return {
id: tab.id,
title: tab.title,
// Same label the tab bar displays (custom title, else derived default such as
// the working-directory basename) — reuse the tab bar's derivation verbatim.
// extensions uses ?. because unit-test stores may omit that reducer.
title: getTabDisplayTitle(tab, state.panes.layouts[tab.id], state.panes.paneTitles?.[tab.id], state.extensions?.entries),
active,
busy: flags.busy,
attention: flags.attention,
pendingApproval: tabHasPendingApproval(state, tab.id),
fill: tileFill(active, flags),
dot: tileDot(flags),
priority: tilePriority(active, flags),
tabIndex: index,
repoIcons: getTabRepoIcons(state, tab),
paneIcons: getTabPaneIcons(state, tab),
}
Expand All @@ -224,7 +231,7 @@ export function selectDeckModel(state: RootState): DeckModel {
// Classic terminal-previews style keeps raw tab-bar order (pre-redesign behavior).
tabs.sort((a, b) => a.priority - b.priority)
}
return { activeTabId, tabs, tileStyle }
return { activeTabId, tabs, tileStyle, keyLayout: state.settings.settings.streamDeck.keyLayout }
}

export type ApproveTarget = {
Expand Down
48 changes: 44 additions & 4 deletions src/deck/frame.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DeckKeyLayout } from '@shared/settings'
import type { DeckCapabilities } from './deck-device'
import type { DeckModel, TilePaneIcon } from './deck-selectors'
import type { DeckModel, DeckTab, TilePaneIcon } from './deck-selectors'
import type { TileFill } from './tile-state'
import { PANE_TINT_COLORS } from './pane-tint-colors'
import { providerIconDataUrl } from './provider-icon-svg'
Expand Down Expand Up @@ -29,8 +30,46 @@ export type LayoutPlan = {
useStrip: boolean
}

export function planLayout(caps: DeckCapabilities, tabCount: number): LayoutPlan {
export type DeckArrangement = 'standard' | 'reversed'

/** Smallest decks (<= 6 keys, e.g. the 6-key Mini) default to the reversed
* "newest first" arrangement under keyLayout 'auto'; larger decks keep the
* status-sorted standard. */
export const AUTO_REVERSED_MAX_KEYS = 6

export function resolveArrangement(keyLayout: DeckKeyLayout, keyCount: number): DeckArrangement {
if (keyLayout === 'newest-first') return 'reversed'
if (keyLayout === 'status-sorted') return 'standard'
return keyCount <= AUTO_REVERSED_MAX_KEYS ? 'reversed' : 'standard'
}

/** Reversed = strictly reverse tab-bar order (newest first while tabs are
* unreordered; after a manual reorder or cross-device order sync it mirrors
* the reversed tab bar) with NO status sorting, so key positions are
* deterministic and muscle-memory-stable (status still shows via
* fills/icons/rings). Standard keeps the model's order: status-sorted for
* status-icons, raw tab-bar order for previews. */
export function arrangeTabs(tabs: DeckTab[], arrangement: DeckArrangement): DeckTab[] {
if (arrangement !== 'reversed') return tabs
return [...tabs].sort((a, b) => b.tabIndex - a.tabIndex)
}

export function planLayout(caps: DeckCapabilities, tabCount: number, arrangement: DeckArrangement): LayoutPlan {
const range = (n: number) => Array.from({ length: n }, (_, i) => i)
if (arrangement === 'reversed') {
const full = caps.dialCount >= 2 && caps.hasTouchStrip
return {
mode: full ? 'full' : 'keys',
keyCount: caps.keyCount,
// Pager ALWAYS reserved at top-left (key 0) — even when all tabs fit —
// so tab positions never shift as the tab count crosses capacity.
tabSlots: range(caps.keyCount - 1).map((i) => i + 1),
pagerKey: 0,
tabsPerPage: caps.keyCount - 1,
useDials: full,
useStrip: caps.hasTouchStrip,
}
}
if (caps.dialCount >= 2 && caps.hasTouchStrip) {
return {
mode: 'full', keyCount: caps.keyCount, tabSlots: range(caps.keyCount),
Expand Down Expand Up @@ -94,7 +133,8 @@ export type FrameInputs = {
}

export function buildFrame({ model, caps, page, actionLayer, iconReady, previewFor }: FrameInputs): FrameSpec {
const plan = planLayout(caps, model.tabs.length)
const arrangement = resolveArrangement(model.keyLayout, caps.keyCount)
const plan = planLayout(caps, model.tabs.length, arrangement)
const pages = pageCount(model.tabs.length, plan.tabsPerPage)
const keys: KeySpec[] = Array.from({ length: plan.keyCount }, () => ({ kind: 'empty' as const }))
const strip: StripSpec = plan.useStrip ? { text: stripText(model, clampPage(page, pages), pages) } : null
Expand All @@ -109,7 +149,7 @@ export function buildFrame({ model, caps, page, actionLayer, iconReady, previewF
}

const current = clampPage(page, pages)
const visible = visibleTabs(model.tabs, current, plan.tabsPerPage)
const visible = visibleTabs(arrangeTabs(model.tabs, arrangement), current, plan.tabsPerPage)
plan.tabSlots.forEach((keyIndex, slot) => {
const tab = visible[slot]
if (!tab) return
Expand Down
26 changes: 18 additions & 8 deletions src/deck/tile-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export const RING_COLORS: Record<Exclude<RingColor, null>, string> = {
export const BANNER_HEIGHT = 20
export const BANNER_FILL = 'rgba(0,0,0,0.667)'
export const TITLE_FONT_SIZE = 16
/** Icons-style banner title. 2px smaller than the classic banner: 16px read
* too large on 72-80px keys. The classic terminal-previews banner is PINNED
* and keeps TITLE_FONT_SIZE = 16. Fit math needs no change: fitLabel is
* measure-driven and Chromium's measureText scales with ctx.font. */
export const ICONS_TITLE_FONT_SIZE = 14
/** Subtle tracking for deck text. Chromium-only canvas API — the deck's only
* supported surface. Set BEFORE measureText so fitLabel includes the tracking
* (Chromium adds it after every glyph, matching the test stub's model). */
Expand All @@ -67,10 +72,15 @@ export const TITLE_SIDE_PADDING = 6
// hues must stay the app's (see docs/plans/2026-07-29-deck-icons-polish.md).
//
// deck constant <- app source token (where it lives) value
// TILE_BG <- --background dark (src/theme-variables.css) hsl(240 10% 4%) = #09090b
// TILE_FILL_GREEN <- bg-emerald-100 (TabItem.tsx green-filled tab) #d1fae5
// light-theme variant: the dark-theme emerald-900/40
// fill is illegible at key size on the LCD.
// TILE_BG <- deck-only pure black (was --background dark #09090b).
// Matches EMPTY_BG, so unfilled tiles read as plain
// black keys: only banner/icons/rings carry state. #000000
// TILE_FILL_GREEN <- bg-emerald-100 (TabItem.tsx green-filled tab) #d1fae5
// precomposited at 50% opacity over the black tile:
// round(c/2) per channel -> rgb(105,125,115) = #697d73.
// Same hue as the tab bar's green, dark enough for the
// LCD. Both fill states (green + barTop) use it; the
// barTop BORDER stays full-strength BAR_TOP_BORDER.
// BAR_TOP_BORDER <- border-t-success / --success (TabItem bar-on-top) hsl(142 71% 45%) = #21c45d
// STATUS_* pane-icon tints (text-success, text-blue-500, ...) live in
// pane-tint-colors.ts — shared with frame.ts, which computes the tinted
Expand All @@ -87,8 +97,8 @@ export const TITLE_SIDE_PADDING = 6
// deliberately not re-derived (that style must not change).
// ============================================================================

export const TILE_BG = '#09090b'
export const TILE_FILL_GREEN = '#d1fae5'
export const TILE_BG = '#000000'
export const TILE_FILL_GREEN = '#697d73'
export const BAR_TOP_BORDER = '#21c45d'
export const ACTIVE_COLOR = '#ffffff'
export const ICON_GAP = 3
Expand Down Expand Up @@ -310,11 +320,11 @@ function drawIconsTab(ctx: Ctx2D, w: number, h: number, spec: Extract<KeySpec, {
ctx.letterSpacing = TEXT_LETTER_SPACING
// Weight rule: 400 everywhere, EXCEPT where the deck mirrors the app UI —
// the letter avatar and +N badge keep RepoIcon's 600 (see RepoIcon.tsx).
ctx.font = `400 ${TITLE_FONT_SIZE}px ${DECK_FONT_STACK}`
ctx.font = `400 ${ICONS_TITLE_FONT_SIZE}px ${DECK_FONT_STACK}`
ctx.textBaseline = 'top'
ctx.fillStyle = ACTIVE_COLOR
const label = fitLabel((t) => ctx.measureText(t).width, truncateTitle(spec.title), w - 2 * TITLE_SIDE_PADDING)
drawCenteredText(ctx, label, w, 2)
drawCenteredText(ctx, label, w, Math.round((BANNER_HEIGHT - ICONS_TITLE_FONT_SIZE) / 2))

// 4. Borders/rings: barTop green border; white ring marks the active tab.
if (spec.fill === 'barTop') {
Expand Down
Loading
Loading