Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5446092
docs: add implementation plan for deck-tile-redesign
danshapiro Jul 29, 2026
e8b2950
docs(deck): harden tile-redesign plan with load-bearing validation fi…
danshapiro Jul 29, 2026
b8e1237
docs(deck): fix plan verification gates and probe placement from fres…
danshapiro Jul 29, 2026
cf36074
docs(deck): fix plan test gates - fixture tab modes, loader call coun…
danshapiro Jul 29, 2026
14ec971
docs(deck): fix plan gaps - deck-manager fixture reducers for probe, …
danshapiro Jul 29, 2026
f5c5848
feat(deck): pure tile classification - fill, dot, and sort priority f…
danshapiro Jul 29, 2026
36eeaa8
feat(deck): getTabStatusFlags - busy/attention/greenIcon from the tab…
danshapiro Jul 29, 2026
0ee9540
feat(deck): getTabRepoIcons - tab-bar repo icon pipeline reused for t…
danshapiro Jul 29, 2026
1b12d83
feat(deck): status-priority sorted DeckModel with fill/dot/repoIcons …
danshapiro Jul 29, 2026
1f0d17a
feat(deck): KeySpec gains fill/dot/icons; buildFrame resolves icon re…
danshapiro Jul 29, 2026
5745df2
feat(deck): IconImageCache - async repo-icon bitmaps with letter-avat…
danshapiro Jul 29, 2026
010664d
feat(deck): tab-bar-matching tile rendering - fills, repo icons, stat…
danshapiro Jul 29, 2026
fb09f7e
feat(deck): controller loads repo icons via IconImageCache, owns the …
danshapiro Jul 29, 2026
ef52f33
refactor(deck): remove terminal preview machinery and status rings
danshapiro Jul 29, 2026
97a7c73
feat(deck): snapshot key target at press-down so re-sorts cannot reta…
danshapiro Jul 29, 2026
14ba0f9
test(deck): e2e coverage for sorted keys, background states, repo ico…
danshapiro Jul 29, 2026
39b9eff
refactor(deck): drop dead tabHasPendingApproval export left by ring r…
danshapiro Jul 29, 2026
8da1e22
docs: add implementation plan for deck-tile-modes
danshapiro Jul 29, 2026
e5263dd
docs(plan): apply load-bearing validation findings to deck-tile-modes…
danshapiro Jul 29, 2026
1302116
docs(plan): fix Task 7 Step 5 e2e gate — account for style:'icons' br…
danshapiro Jul 29, 2026
be8f342
feat(deck): streamDeck.tileStyle local setting - status-icons default…
danshapiro Jul 29, 2026
3a3d0a7
test(deck): guard tileStyle extraction whitelists with positive-path …
danshapiro Jul 29, 2026
3c286a8
feat(deck): tile style setting UI with a11y-labeled segmented control
danshapiro Jul 29, 2026
bbfc6d5
feat(deck): restore terminal-text registry and TerminalView registrat…
danshapiro Jul 29, 2026
dba9edd
feat(deck): pendingApproval flag, tileStyle on DeckModel, sort gated …
danshapiro Jul 29, 2026
7c6c421
feat(deck): strip waiting count unions needs-attention and waiting-fo…
danshapiro Jul 29, 2026
20efadb
feat(deck): dual tile-style KeySpec with restored classic preview+rin…
danshapiro Jul 29, 2026
745093c
feat(deck): restore preview capture polling, gated to the terminal-pr…
danshapiro Jul 29, 2026
196f3e1
test(deck): e2e coverage for tile-style switching, classic previews, …
danshapiro Jul 29, 2026
a30d3fd
docs: describe the Stream Deck tile-style setting and strip waiting s…
danshapiro Jul 29, 2026
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- **AI-powered session titles** — Right-click any session and generate a Gemini-powered title based on conversation content
- **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search
- **Mobile responsive** — Auto-collapsing sidebar and overlay navigation for phones and tablets
- **Stream Deck** — Drive freshell from an Elgato Stream Deck: tabs on keys with live previews and status rings, press to focus, long-press to approve or stop agents. See [Stream Deck](#stream-deck).
- **Stream Deck** — Drive freshell from an Elgato Stream Deck: tabs on keys with repo icons and status backgrounds (or classic live previews and status rings), press to focus, long-press to approve or stop agents. See [Stream Deck](#stream-deck).

## Quick Start

Expand Down Expand Up @@ -67,7 +67,7 @@ npm run serve # Production build and run

## Stream Deck

Freshell can drive an Elgato Stream Deck straight from the browser. Each key shows a tab — title, live terminal preview, and a status ring (busy, needs attention, waiting for approval). Press a key to focus that tab; long-press (500 ms) to open an action layer with BACK / APPROVE / STOP keys (it closes itself after 10 s). When you have more tabs than keys, the last key pages through them (wrapping around). On a Stream Deck +, the dials cycle tabs and flip pages and the touch strip shows the active tab. The deck dims after a configurable idle timeout and wakes on activity.
Freshell can drive an Elgato Stream Deck straight from the browser. Each key shows a tab — by default the **Status icons** style: title on top, centered repo icons, and a status background (green for tabs that want attention), with keys sorted so attention-seeking tabs come first. Press a key to focus that tab; long-press (500 ms) to open an action layer with BACK / APPROVE / STOP keys (it closes itself after 10 s). When you have more tabs than keys, the last key pages through them (wrapping around). On a Stream Deck +, the dials cycle tabs and flip pages and the touch strip shows the active tab plus busy/waiting counts (waiting = tabs that finished a turn or are waiting for approval). The deck dims after a configurable idle timeout and wakes on activity.

**Requirements**

Expand All @@ -78,6 +78,8 @@ Freshell can drive an Elgato Stream Deck straight from the browser. Each key sho

**Virtual deck:** Settings → Stream Deck → **Show virtual deck** opens an on-screen deck panel that mirrors the keys. It works without any hardware — and in browsers without WebHID.

**Tile style:** Settings → Stream Deck → **Tile style** switches between **Status icons** (the default, described above) and **Terminal previews** — the classic look with a title banner, a live mini terminal preview on each key, and colored status rings (blue busy, green needs-attention, amber waiting for approval), with keys in plain tab-bar order. Switching takes effect immediately, on the hardware deck and the virtual deck alike.

**Linux device permissions (udev):** hidraw device nodes default to root-only, so the browser cannot open the deck until you grant access to the Elgato vendor id (`0fd9`):

```bash
Expand Down
1,281 changes: 1,281 additions & 0 deletions docs/plans/2026-07-29-deck-tile-modes.md

Large diffs are not rendered by default.

1,884 changes: 1,884 additions & 0 deletions docs/plans/2026-07-29-deck-tile-redesign.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const OSC52_CLIPBOARD_VALUES = ['ask', 'always', 'never'] as const
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
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 @@ -95,6 +96,7 @@ export type Osc52ClipboardPolicy = (typeof OSC52_CLIPBOARD_VALUES)[number]
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 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 @@ -225,6 +227,7 @@ export type LocalSettings = {
brightness: number
idleBrightness: number
idleTimeoutSeconds: number
tileStyle: DeckTileStyle
}
}

Expand Down Expand Up @@ -260,6 +263,7 @@ const Osc52ClipboardSchema = z.enum(OSC52_CLIPBOARD_VALUES)
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 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 @@ -642,6 +646,9 @@ function normalizeExtractedLocalSeed(patch: Record<string, unknown>): LocalSetti
if (typeof patch.streamDeck.idleTimeoutSeconds === 'number') {
streamDeck.idleTimeoutSeconds = patch.streamDeck.idleTimeoutSeconds as number
}
if (DeckTileStyleSchema.safeParse(patch.streamDeck.tileStyle).success) {
streamDeck.tileStyle = patch.streamDeck.tileStyle as DeckTileStyle
}
if (Object.keys(streamDeck).length > 0) {
normalized.streamDeck = streamDeck
}
Expand Down Expand Up @@ -897,6 +904,7 @@ export const defaultLocalSettings: LocalSettings = {
brightness: 100,
idleBrightness: 10,
idleTimeoutSeconds: 300,
tileStyle: 'status-icons',
},
}

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

Expand Down
4 changes: 2 additions & 2 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ import {
import { useMobile } from '@/hooks/useMobile'
import { useKeyboardInset } from '@/hooks/useKeyboardInset'
import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry'
import { useTerminalTextRegistration } from '@/deck/terminal-text-registry'
import { findLocalFilePaths } from '@/lib/path-utils'
import { findUrls } from '@/lib/url-utils'
import { openExternalUrl, shouldOpenLinkExternally } from '@/lib/open-url'
Expand Down Expand Up @@ -163,6 +162,7 @@ import {
scrollLinesToCursorKeys,
shouldTranslateScrollToCursorKeys,
} from '@/lib/terminal-behavior'
import { useTerminalTextRegistration } from '@/deck/terminal-text-registry'
import { buildRestoreError, sanitizeSessionRef } from '@shared/session-contract'

const log = createLogger('TerminalView')
Expand Down Expand Up @@ -673,7 +673,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
const isTerminal = paneContent.kind === 'terminal'
const terminalContent = isTerminal ? paneContent : null

// Register live terminal text reader for Stream Deck previews
// Register live terminal text reader for Stream Deck previews (classic tile style)
useTerminalTextRegistration(terminalContent?.terminalId, termRef)

const extensions = useAppSelector((s) => s.extensions?.entries ?? [], shallowEqual)
Expand Down
4 changes: 3 additions & 1 deletion src/components/VirtualDeckPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { setVirtualDeckOpen } from '@/store/deckSlice'
import { FakeDeckDevice, MINI_CAPS, PLUS_CAPS } from '@/deck/fake-deck-device'
import { DeckController } from '@/deck/deck-controller'
import { renderKey, renderStrip, type Ctx2D, type CtxFactory } from '@/deck/tile-renderer'
import { getIconImageCache } from '@/deck/icon-image-cache'
import { SegmentedControl } from '@/components/settings/settings-controls'

type Profile = 'mini' | 'plus'
Expand All @@ -25,6 +26,7 @@ function noopCtx(width: number, height: number): Ctx2D {
textBaseline: 'top' as CanvasTextBaseline,
fillRect: () => {},
fillText: () => {},
drawImage: () => {},
measureText: () => ({ width: 0 }) as TextMetrics,
getImageData: () => ({ data: new Uint8ClampedArray(width * height * 4) }) as ImageData,
}
Expand Down Expand Up @@ -81,7 +83,7 @@ export default function VirtualDeckPanel() {
const controller = new DeckController({
store,
device,
renderKey: (spec, c) => renderKey(spec, c, safeCtxFactory),
renderKey: (spec, c) => renderKey(spec, c, safeCtxFactory, (url) => getIconImageCache().bitmapFor(url)),
renderStrip: (text, width, height) => renderStrip(text, width, height, safeCtxFactory),
settings: () => store.getState().settings.settings.streamDeck,
})
Expand Down
20 changes: 20 additions & 0 deletions src/components/settings/StreamDeckSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ 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 {
SettingsSection,
SettingsRow,
SegmentedControl,
SteppedRangeInput,
Toggle,
} from './settings-controls'
Expand Down Expand Up @@ -74,6 +76,24 @@ export default function StreamDeckSettings({
/>
</SettingsRow>

<SettingsRow
label="Tile style"
description="Status icons shows repo icons with status backgrounds, sorted by attention. Terminal previews shows live terminal output with status rings, in tab-bar order."
>
<SegmentedControl
value={streamDeck.tileStyle}
aria-label="Tile style"
options={[
{ value: 'status-icons', label: 'Status icons' },
{ value: 'terminal-previews', label: 'Terminal previews' },
]}
onChange={(v: string) => {
const tileStyle = v as DeckTileStyle
applyLocalSetting({ streamDeck: { tileStyle } })
}}
/>
</SettingsRow>

{connectAvailable && (
<SettingsRow label="Connection" description={deckStatusText(deck)}>
<button
Expand Down
10 changes: 9 additions & 1 deletion src/components/settings/settings-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,24 @@ export function SegmentedControl({
value,
options,
onChange,
'aria-label': ariaLabel,
}: {
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
'aria-label'?: string
}) {
return (
<div className="flex w-full min-w-0 flex-wrap bg-muted rounded-md p-0.5 md:w-auto md:min-w-[12rem]">
<div
role="group"
aria-label={ariaLabel}
className="flex w-full min-w-0 flex-wrap bg-muted rounded-md p-0.5 md:w-auto md:min-w-[12rem]"
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
'min-h-10 flex-1 px-3 py-1 text-xs rounded-md transition-colors md:min-h-0 md:flex-none',
Expand Down
Loading
Loading