pumi

Themes

Redraw the whole window: colours, layout, and any part in React.

A theme is a plugin with a themes entry in its manifest. It sets the window's colours, the terminal palette, metrics and fonts, and it can replace any part of the window, from one button to the whole layout, with a React component.

The fastest start is a theme plugin made from the default theme:

pumi plugin new mytheme --theme default   # a plugin whose theme.tsx starts from the default's look
pumi plugin dev mytheme             # link it, and reload it as you edit it
mytheme/
  package.json          "themes": ["themes/mytheme"] in its "pumi" key
  themes/mytheme/
    theme.tsx           export default defineTheme({ … })

defineTheme

theme.tsx exports one defineTheme. Everything in it is optional; what you leave out comes from the default theme.

import { defineTheme, useTheme } from "pumi";

function View({ index, active }: { view: string; active: boolean; index: number }) {
  const t = useTheme();
  return <text style={{ height: 22, paddingLeft: 10, paddingRight: 10, color: active ? t.accent : t.faint }}>{`${index + 1}`}</text>;
}

export default defineTheme({
  name: "My Theme",
  colors: { dark: { accent: "#ff6b6b" }, light: { accent: "#c92a2a" } },
  terminal: { dark: { background: "#101014" } },
  metrics: { radius: 0, fontSize: 13 },
  fonts: [`${import.meta.dir}/fonts/pixel.ttf`],
  components: { View },
});
Key
colorswindow surface editor text muted faint border hover active selected accent accentText success error warning overlay status.<status> (a tab's @status: working, blocked, error, done, idle, unknown)
terminalbackground foreground cursor selection ansi, palette (colours 0–255 pinned by index)
metricsradius radiusSmall fontSize paneFade (how much an unfocused terminal fades, 0–1), backgroundOpacity (the default window's backgrounds, 0–1; under 1 they let the desktop through), bar heights
fontsfont files
window{ background: "opaque" | "transparent" | "blurred", blur } (blur, macOS: px of the window server's blur behind a background that is not opaque)
layoutgap, divider, lifted; or a function (tokens, metrics, option) => ({ … }), where option("pane_style") reads the theme's own setting, drawn again as it changes
fromPalette(palette, appearance) => tokens: colours made from the terminals' palette (the picked colour scheme, else the theme's terminal), over colors, so the window matches whatever scheme is picked; with a scheme picked, the look is the scheme's
docksextra sidebars
componentsparts to replace

Above the sixteen ANSI colours, terminals use xterm's 256-colour cube and grey ramp. With terminal_palette_generate on (Settings: "Generate 256 colors from the scheme"), pumi builds them from the sixteen, the background and the foreground instead, as Ghostty's palette-generate does, so programs that use 256 colours match the scheme. terminal_palette_harmonious keeps a light background's generated colours running from light to dark. Generation never overwrites a colour a theme pins in palette.

Pick a theme with pumi option set ui_theme NAME, or in settings.json ("options": { "ui_theme": "mytheme" }). If one throws, the window falls back to the default theme, and the app started with --safe (or Settings → Plugins → Safe mode) runs without any plugin.

The default theme has settings of its own, under the theme picker in Settings: ui_font_size (the window's text, 10–16pt), ui_inactive_pane_fade (0–80%), ui_pane_gap (px), ui_radius (px), ui_accent (#rrggbb), ui_background_opacity (20–100%, 95 by default; 100 is opaque, lower shows the desktop through the window) and ui_background_blur (0–60px, 40 by default; how much what shows through is blurred). A plugin's theme declares its own in its manifest and reads them with useThemeOption.

It also has settings for how it behaves, in sections under those: Panes (ui_pane_titles, title bars over panes; ui_pane_outline, an outline on the focused pane; ui_pane_equalize, even splits as tabs open and close; ui_focus_follows_mouse), Side bar (ui_sidebar_swipe, a swipe left to hide it; ui_view_icons, how many tab icons a view shows, 0–3) and Motion and feedback (animate, tabs gliding into place; haptics, trackpad taps from switches and sliders). They are the default theme's, not the app's: a plugin's theme that wants them declares the same keys, unprefixed, in its manifest (contributes.options.pane_titles, pane_outline, pane_equalize, focus_follows_mouse, sidebar_swipe, view_icons, animate, haptics), and Settings shows them on its page; one it leaves out keeps its default. The kit reads the drawing theme's own: usePaneTitles, useOutlineFocused, useAutoEqualize, useFocusOnHover(tab, focused) (a pane's onMouseEnter), useSidebarSwipe(hide) (its side bar's onScroll), useViewIcons, useAnimate and useThemeSetting(key), from pumi/theme; toggle_pane_titles and toggle_auto_equalize set the drawing theme's too.

Parts

Each part of the window is a component. Give one in components and it draws that part; the app's own draw the rest, and a part you give draws the ones inside it with useComponents().

Window Starting ViewBar View Layout Split Pane PaneTitle PaneTab TabBody Splitter Float Panel Sessions TabRow TabRowControls Action Menu Dialog IconButton TabIcon

Sessions is the side bar's tree of sessions, views and tabs. TabRow is a tab with a @status in it, and TabRowControls the tab.row.controls contributions on that row.

TabIcon is a tab's icon wherever one is shown: its pane's strip, the side bar, a view's tabs, a tab being dragged. It is given {tab, size, color}. useTabIcon(tab) says what the tab's icon is ({src, tint, from}: its own @icon, its block's, or the core's), and IconImage draws one, tinted by color when tint is set. The app's own shows the tab's status (useTabStatus) in its place while it is working, done, blocked or failed (showsStatus). Draw <TabIcon tab={id} /> from pumi in your own parts and they show what the theme's TabIcon does.

A Pane is given its id, its tabs and the one in front (active). Its PaneTitle draws the strip of its tabs, one PaneTab each, while panes hold tabs (pane_tabs, on by default), and the front tab's title while they don't. TabBody draws a tab: only the front tab's is drawn, and the others keep running behind it. A tab on the terminal engine that no renderer draws is drawn as a plain <terminal tab>. A zoomed pane fills its view with its strip still on top. Draw the focus from the focused a part is given, or useFocusedTab(view) from pumi/theme, rather than from view.activeTab: they are the focus as this window has it.

import { useTheme } from "pumi";
import { useComponents } from "pumi/theme";

function Pane({ pane, tabs, active, focused, zoomed }: { pane: string; tabs: string[]; active?: string | null; focused: boolean; zoomed: boolean }) {
  const t = useTheme();
  const C = useComponents();
  const front = active ?? tabs[0];
  return (
    <div style={{ borderWidth: 2, borderColor: t.accent, flexDirection: "column", flexGrow: 1 }}>
      <C.PaneTitle pane={pane} tabs={tabs} active={front} focused={focused} zoomed={zoomed} menu={() => {}} />
      <C.TabBody key={front} tab={front} focused={focused} />
    </div>
  );
}

Docks

A dock is a sidebar that your Window places. docks declares it; pumi gives it a toggle_<name> command, a shortcut, a size the user can drag, and the plugins' panels that match panels.

import { defineTheme } from "pumi";
import { Dock, renderComponent, useComponents, useDockPanels } from "pumi/theme";

function Surfaces() {
  const { components, shown, show } = useDockPanels("surfaces");
  return shown ? renderComponent(shown) : components.map((c) => <text key={c.id} onClick={() => show(c)}>{c.title}</text>);
}

function Window() {
  const C = useComponents();
  return (
    <div style={{ display: "flex", flexDirection: "row", width: "100%", height: "100%" }}>
      <Dock name="sidebar" edge="left"><C.Sessions /></Dock>
      <div style={{ flexGrow: 1 }}><C.Layout /></div>
      <Dock name="surfaces" edge="right"><Surfaces /></Dock>
    </div>
  );
}

export default defineTheme({
  name: "Two Sides",
  docks: { surfaces: { title: "Surfaces", keys: "cmd+alt+b", size: 320, panels: ["*"] } },
  components: { Window },
});

Styling

Styles are objects, not CSS: flexbox, plus gradients, nine-slice borders, sprite sheets and motion. Take colours from useTheme() so they follow light and dark.

<div style={{ background: { type: "linear-gradient", angle: 90, stops: [{ color: t.accent }, { color: t.surface }] } }} />
<div style={{ backgroundImage: floor, backgroundSize: 32, imageRendering: "pixelated" }} />
<div style={{ borderImage: { src: frame, slice: 8 } }} />
<img src={walk} sprite={{ columns: 4, fps: 8 }} />
<motion.div animate={{ opacity: [1, 0.4, 1] }} transition={{ duration: 1.2, repeat: Infinity }} />
<motion.svg src={spinner} animate={{ rotate: [0, 360] }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} />  {/* rotate: svg only, in degrees */}
<div style={{ height: 22, appRegion: "drag" }} />      {/* a title bar */}

Files beside the theme, images and fonts, are paths from `${import.meta.dir}/…`.