detached 253fc6a

Merge pull request #125 from bnema/feat/readme-demo

docs: add reproducible vhs demo gif to readme
commit
253fc6a4e7bfa593299d42551a163ae7a59e5055
author
bnema · · 2026-07-24 13:05 UTC
committer
GitHub · · 2026-07-24 13:05 UTC
merge of
750b4f5 + b6e3279 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +470 -4
M .gitignore +1 -0
@@ -3,3 +3,4 @@
33 .superpowers/
44 docs/superpowers/
55 .worktrees/
6+scripts/demo/out/
@@ -3,3 +3,4 @@
3 .superpowers/3 .superpowers/
4 docs/superpowers/4 docs/superpowers/
5 .worktrees/5 .worktrees/
6+scripts/demo/out/
M Makefile +8 -1
@@ -1,4 +1,4 @@
1-.PHONY: install test test-installer lint mocks
1+.PHONY: install test test-installer lint mocks demo
22
33 install:
44 go install .
@@ -24,3 +24,10 @@ mocks:
2424 exit 1; \
2525 fi
2626 mockery
27+
28+demo:
29+ docker build -f scripts/demo/Dockerfile -t vev-demo .
30+ ./scripts/demo/run.sh demo.tape
31+ docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -O3 --lossy=80 -o /out/demo.gif /out/demo.gif
32+ mkdir -p docs/assets
33+ cp scripts/demo/out/demo.gif docs/assets/demo.gif
@@ -1,4 +1,4 @@
1-.PHONY: install test test-installer lint mocks1+.PHONY: install test test-installer lint mocks demo
2 2
3 install:3 install:
4 go install .4 go install .
@@ -24,3 +24,10 @@ mocks:
24 exit 1; \24 exit 1; \
25 fi25 fi
26 mockery26 mockery
27+
28+demo:
29+ docker build -f scripts/demo/Dockerfile -t vev-demo .
30+ ./scripts/demo/run.sh demo.tape
31+ docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -O3 --lossy=80 -o /out/demo.gif /out/demo.gif
32+ mkdir -p docs/assets
33+ cp scripts/demo/out/demo.gif docs/assets/demo.gif
M README.md +3 -0
@@ -2,6 +2,8 @@
22
33 *Norwegian: to weave*
44
5+<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
6+
57 A terminal multiplexer for Linux and macOS (arm64). One Go binary, a per-user daemon, a thin client.
68
79 What it is built around:
@@ -90,4 +92,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa
9092 make test # go test ./... -race
9193 make lint # goimports check, go vet
9294 make mocks # regenerate mocks
95+make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials)
9396 ```
@@ -2,6 +2,8 @@
2 2
3 *Norwegian: to weave*3 *Norwegian: to weave*
4 4
5+<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
6+
5 A terminal multiplexer for Linux and macOS (arm64). One Go binary, a per-user daemon, a thin client.7 A terminal multiplexer for Linux and macOS (arm64). One Go binary, a per-user daemon, a thin client.
6 8
7 What it is built around:9 What it is built around:
@@ -90,4 +92,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa
90 make test # go test ./... -race92 make test # go test ./... -race
91 make lint # goimports check, go vet93 make lint # goimports check, go vet
92 make mocks # regenerate mocks94 make mocks # regenerate mocks
95+make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials)
93 ```96 ```
A docs/assets/demo.gif +0 -0 binary file: no text diff

binary file: no text diff

M internal/usecase/theme/theme.go +15 -3
@@ -478,7 +478,7 @@ func (d Dimmer) Dim(style renderer.Style) renderer.Style {
478478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {
479479 underline := style.UnderlineColorRGB
480480 if !style.HasUnderlineColorRGB {
481- underline = xterm256Color(style.UnderlineColor)
481+ underline = indexedToRGB(d.theme, style.UnderlineColor)
482482 }
483483 out.HasUnderlineColor = false
484484 out.HasUnderlineColorRGB = true
@@ -492,7 +492,7 @@ func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
492492 return style.ForegroundRGB
493493 }
494494 if style.Foreground >= 0 {
495- return xterm256Color(style.Foreground)
495+ return indexedToRGB(t, style.Foreground)
496496 }
497497 return t.Foreground
498498 }
@@ -502,11 +502,23 @@ func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
502502 return style.BackgroundRGB
503503 }
504504 if style.Background >= 0 {
505- return xterm256Color(style.Background)
505+ return indexedToRGB(t, style.Background)
506506 }
507507 return t.Background
508508 }
509509
510+// indexedToRGB resolves an ANSI color index through the terminal's reported
511+// palette when available, so dimming blends toward what the client actually
512+// renders rather than the hardcoded classic xterm table. It falls back to
513+// the xterm table for the 256-cube, for unreported/unknown slots, and when
514+// palette inheritance is disabled.
515+func indexedToRGB(t Theme, index int) renderer.RGB {
516+ if rgb, ok := t.PaletteColor(index); ok {
517+ return rgb
518+ }
519+ return xterm256Color(index)
520+}
521+
510522 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
511523 return renderer.RGB{
512524 R: blendBytePercent(a.R, b.R, percent),
@@ -478,7 +478,7 @@ func (d Dimmer) Dim(style renderer.Style) renderer.Style {
478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {478 if style.HasUnderlineColorRGB || style.HasUnderlineColor {
479 underline := style.UnderlineColorRGB479 underline := style.UnderlineColorRGB
480 if !style.HasUnderlineColorRGB {480 if !style.HasUnderlineColorRGB {
481- underline = xterm256Color(style.UnderlineColor)481+ underline = indexedToRGB(d.theme, style.UnderlineColor)
482 }482 }
483 out.HasUnderlineColor = false483 out.HasUnderlineColor = false
484 out.HasUnderlineColorRGB = true484 out.HasUnderlineColorRGB = true
@@ -492,7 +492,7 @@ func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
492 return style.ForegroundRGB492 return style.ForegroundRGB
493 }493 }
494 if style.Foreground >= 0 {494 if style.Foreground >= 0 {
495- return xterm256Color(style.Foreground)495+ return indexedToRGB(t, style.Foreground)
496 }496 }
497 return t.Foreground497 return t.Foreground
498 }498 }
@@ -502,11 +502,23 @@ func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
502 return style.BackgroundRGB502 return style.BackgroundRGB
503 }503 }
504 if style.Background >= 0 {504 if style.Background >= 0 {
505- return xterm256Color(style.Background)505+ return indexedToRGB(t, style.Background)
506 }506 }
507 return t.Background507 return t.Background
508 }508 }
509 509
510+// indexedToRGB resolves an ANSI color index through the terminal's reported
511+// palette when available, so dimming blends toward what the client actually
512+// renders rather than the hardcoded classic xterm table. It falls back to
513+// the xterm table for the 256-cube, for unreported/unknown slots, and when
514+// palette inheritance is disabled.
515+func indexedToRGB(t Theme, index int) renderer.RGB {
516+ if rgb, ok := t.PaletteColor(index); ok {
517+ return rgb
518+ }
519+ return xterm256Color(index)
520+}
521+
510 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {522 func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
511 return renderer.RGB{523 return renderer.RGB{
512 R: blendBytePercent(a.R, b.R, percent),524 R: blendBytePercent(a.R, b.R, percent),
M internal/usecase/theme/theme_test.go +136 -0
@@ -429,6 +429,142 @@ func TestDimmerDefaults(t *testing.T) {
429429 }
430430 }
431431
432+// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
433+// colors (slots 0-15) through the terminal's reported palette when known,
434+// so dimming a pane blends toward what the client actually rendered rather
435+// than the hardcoded classic xterm table. It falls back to the xterm table
436+// when the palette is unreported, disabled, or the slot is outside 0-15.
437+func TestDimmerUsesReportedPalette(t *testing.T) {
438+ background := renderer.RGB{R: 10, G: 20, B: 30}
439+ foreground := renderer.RGB{R: 200, G: 200, B: 200}
440+ baseTheme := Theme{
441+ Foreground: foreground, Background: background,
442+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
443+ }
444+
445+ reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446+ reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
447+ reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
448+
449+ withPalette := baseTheme
450+ withPalette.UsePalette = true
451+ withPalette.Palette[2] = reportedSlot2
452+ withPalette.Palette[4] = reportedSlot4
453+ withPalette.Palette[6] = reportedSlot6
454+ withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
455+
456+ unknownBits := baseTheme
457+ unknownBits.UsePalette = true
458+ unknownBits.Palette[2] = reportedSlot2
459+ unknownBits.Palette[4] = reportedSlot4
460+ unknownBits.Palette[6] = reportedSlot6
461+ // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
462+
463+ paletteDisabled := baseTheme
464+ paletteDisabled.UsePalette = false
465+ paletteDisabled.Palette[2] = reportedSlot2
466+ paletteDisabled.PaletteKnown = 1 << 2
467+
468+ dimmer := NewDimmer(baseTheme)
469+ dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
470+ dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
471+ ptr := func(c renderer.RGB) *renderer.RGB { return &c }
472+
473+ underlined := renderer.Style{
474+ Foreground: -1, Background: -1,
475+ HasUnderlineColor: true, UnderlineColor: 6,
476+ }
477+
478+ cases := []struct {
479+ name string
480+ theme Theme
481+ style renderer.Style
482+ // Raw colors Dim's blends must start from; expected values are
483+ // derived from them with the same blend math Dim uses.
484+ fgSource renderer.RGB
485+ bgSource renderer.RGB
486+ underlineSource *renderer.RGB // nil when the style has no underline color
487+ forbidFGSource *renderer.RGB // a raw color Dim must NOT have blended from
488+ }{
489+ {
490+ name: "indexed foreground blends from the reported palette color when known",
491+ theme: withPalette,
492+ style: renderer.Style{Foreground: 2, Background: -1},
493+ fgSource: reportedSlot2,
494+ bgSource: background,
495+ forbidFGSource: ptr(xterm256Color(2)),
496+ },
497+ {
498+ name: "indexed foreground falls back to xterm when the palette bit is unknown",
499+ theme: unknownBits,
500+ style: renderer.Style{Foreground: 2, Background: -1},
501+ fgSource: xterm256Color(2),
502+ bgSource: background,
503+ },
504+ {
505+ name: "indexed foreground falls back to xterm when UsePalette is disabled",
506+ theme: paletteDisabled,
507+ style: renderer.Style{Foreground: 2, Background: -1},
508+ fgSource: xterm256Color(2),
509+ bgSource: background,
510+ },
511+ {
512+ name: "indexed background blends from the reported palette color when known",
513+ theme: withPalette,
514+ style: renderer.Style{Foreground: -1, Background: 4},
515+ fgSource: foreground,
516+ bgSource: reportedSlot4,
517+ },
518+ {
519+ name: "indexed background falls back to xterm when the palette bit is unknown",
520+ theme: unknownBits,
521+ style: renderer.Style{Foreground: -1, Background: 4},
522+ fgSource: foreground,
523+ bgSource: xterm256Color(4),
524+ },
525+ {
526+ name: "indexed underline blends from the reported palette color when known",
527+ theme: withPalette,
528+ style: underlined,
529+ fgSource: foreground,
530+ bgSource: background,
531+ underlineSource: ptr(reportedSlot6),
532+ },
533+ {
534+ name: "indexed underline falls back to xterm when the palette bit is unknown",
535+ theme: unknownBits,
536+ style: underlined,
537+ fgSource: foreground,
538+ bgSource: background,
539+ underlineSource: ptr(xterm256Color(6)),
540+ },
541+ {
542+ name: "256-cube index is untouched by a reported palette",
543+ theme: withPalette,
544+ style: renderer.Style{Foreground: 196, Background: -1},
545+ fgSource: xterm256Color(196),
546+ bgSource: background,
547+ },
548+ }
549+
550+ for _, tc := range cases {
551+ t.Run(tc.name, func(t *testing.T) {
552+ got := NewDimmer(tc.theme).Dim(tc.style)
553+
554+ dimmedBG := dimBG(tc.bgSource)
555+ require.Equal(t, dimmedBG, got.BackgroundRGB)
556+ require.Equal(t, dimFG(tc.fgSource, dimmedBG), got.ForegroundRGB)
557+ if tc.forbidFGSource != nil {
558+ require.NotEqual(t, dimFG(*tc.forbidFGSource, dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
559+ }
560+ if tc.underlineSource != nil {
561+ require.True(t, got.HasUnderlineColorRGB)
562+ require.Equal(t, dimFG(*tc.underlineSource, dimmedBG), got.UnderlineColorRGB)
563+ }
564+ })
565+ }
566+}
567+
432568 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
433569 unknown := Theme{}
434570 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
@@ -429,6 +429,142 @@ func TestDimmerDefaults(t *testing.T) {
429 }429 }
430 }430 }
431 431
432+// TestDimmerUsesReportedPalette verifies that Dim resolves indexed ANSI
433+// colors (slots 0-15) through the terminal's reported palette when known,
434+// so dimming a pane blends toward what the client actually rendered rather
435+// than the hardcoded classic xterm table. It falls back to the xterm table
436+// when the palette is unreported, disabled, or the slot is outside 0-15.
437+func TestDimmerUsesReportedPalette(t *testing.T) {
438+ background := renderer.RGB{R: 10, G: 20, B: 30}
439+ foreground := renderer.RGB{R: 200, G: 200, B: 200}
440+ baseTheme := Theme{
441+ Foreground: foreground, Background: background,
442+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
443+ }
444+
445+ reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446+ reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
447+ reportedSlot6 := renderer.RGB{R: 50, G: 150, B: 150}
448+
449+ withPalette := baseTheme
450+ withPalette.UsePalette = true
451+ withPalette.Palette[2] = reportedSlot2
452+ withPalette.Palette[4] = reportedSlot4
453+ withPalette.Palette[6] = reportedSlot6
454+ withPalette.PaletteKnown = 1<<2 | 1<<4 | 1<<6
455+
456+ unknownBits := baseTheme
457+ unknownBits.UsePalette = true
458+ unknownBits.Palette[2] = reportedSlot2
459+ unknownBits.Palette[4] = reportedSlot4
460+ unknownBits.Palette[6] = reportedSlot6
461+ // PaletteKnown left zero: reported RGBs exist but no bit says they're valid.
462+
463+ paletteDisabled := baseTheme
464+ paletteDisabled.UsePalette = false
465+ paletteDisabled.Palette[2] = reportedSlot2
466+ paletteDisabled.PaletteKnown = 1 << 2
467+
468+ dimmer := NewDimmer(baseTheme)
469+ dimBG := func(raw renderer.RGB) renderer.RGB { return blendPercent(raw, background, dimmer.backgroundPercent) }
470+ dimFG := func(raw, bg renderer.RGB) renderer.RGB { return blendPercent(raw, bg, dimmer.foregroundPercent) }
471+ ptr := func(c renderer.RGB) *renderer.RGB { return &c }
472+
473+ underlined := renderer.Style{
474+ Foreground: -1, Background: -1,
475+ HasUnderlineColor: true, UnderlineColor: 6,
476+ }
477+
478+ cases := []struct {
479+ name string
480+ theme Theme
481+ style renderer.Style
482+ // Raw colors Dim's blends must start from; expected values are
483+ // derived from them with the same blend math Dim uses.
484+ fgSource renderer.RGB
485+ bgSource renderer.RGB
486+ underlineSource *renderer.RGB // nil when the style has no underline color
487+ forbidFGSource *renderer.RGB // a raw color Dim must NOT have blended from
488+ }{
489+ {
490+ name: "indexed foreground blends from the reported palette color when known",
491+ theme: withPalette,
492+ style: renderer.Style{Foreground: 2, Background: -1},
493+ fgSource: reportedSlot2,
494+ bgSource: background,
495+ forbidFGSource: ptr(xterm256Color(2)),
496+ },
497+ {
498+ name: "indexed foreground falls back to xterm when the palette bit is unknown",
499+ theme: unknownBits,
500+ style: renderer.Style{Foreground: 2, Background: -1},
501+ fgSource: xterm256Color(2),
502+ bgSource: background,
503+ },
504+ {
505+ name: "indexed foreground falls back to xterm when UsePalette is disabled",
506+ theme: paletteDisabled,
507+ style: renderer.Style{Foreground: 2, Background: -1},
508+ fgSource: xterm256Color(2),
509+ bgSource: background,
510+ },
511+ {
512+ name: "indexed background blends from the reported palette color when known",
513+ theme: withPalette,
514+ style: renderer.Style{Foreground: -1, Background: 4},
515+ fgSource: foreground,
516+ bgSource: reportedSlot4,
517+ },
518+ {
519+ name: "indexed background falls back to xterm when the palette bit is unknown",
520+ theme: unknownBits,
521+ style: renderer.Style{Foreground: -1, Background: 4},
522+ fgSource: foreground,
523+ bgSource: xterm256Color(4),
524+ },
525+ {
526+ name: "indexed underline blends from the reported palette color when known",
527+ theme: withPalette,
528+ style: underlined,
529+ fgSource: foreground,
530+ bgSource: background,
531+ underlineSource: ptr(reportedSlot6),
532+ },
533+ {
534+ name: "indexed underline falls back to xterm when the palette bit is unknown",
535+ theme: unknownBits,
536+ style: underlined,
537+ fgSource: foreground,
538+ bgSource: background,
539+ underlineSource: ptr(xterm256Color(6)),
540+ },
541+ {
542+ name: "256-cube index is untouched by a reported palette",
543+ theme: withPalette,
544+ style: renderer.Style{Foreground: 196, Background: -1},
545+ fgSource: xterm256Color(196),
546+ bgSource: background,
547+ },
548+ }
549+
550+ for _, tc := range cases {
551+ t.Run(tc.name, func(t *testing.T) {
552+ got := NewDimmer(tc.theme).Dim(tc.style)
553+
554+ dimmedBG := dimBG(tc.bgSource)
555+ require.Equal(t, dimmedBG, got.BackgroundRGB)
556+ require.Equal(t, dimFG(tc.fgSource, dimmedBG), got.ForegroundRGB)
557+ if tc.forbidFGSource != nil {
558+ require.NotEqual(t, dimFG(*tc.forbidFGSource, dimmedBG), got.ForegroundRGB, "must not use the xterm table when the palette is known")
559+ }
560+ if tc.underlineSource != nil {
561+ require.True(t, got.HasUnderlineColorRGB)
562+ require.Equal(t, dimFG(*tc.underlineSource, dimmedBG), got.UnderlineColorRGB)
563+ }
564+ })
565+ }
566+}
567+
432 func TestStyleHelpersFallbackAndThemed(t *testing.T) {568 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
433 unknown := Theme{}569 unknown := Theme{}
434 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {570 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
A scripts/demo/Dockerfile +33 -0
@@ -0,0 +1,33 @@
1+# syntax=docker/dockerfile:1
2+FROM golang:1.26 AS build
3+WORKDIR /src
4+COPY go.mod go.sum ./
5+RUN go mod download
6+COPY . .
7+RUN CGO_ENABLED=0 go build -o /out/vev .
8+
9+FROM archlinux:latest
10+# The base image ships a pacman.conf that skips extracting man/info pages
11+# (NoExtract) to stay small; the demo wants a real `man ls`, so drop that
12+# rule and reinstall coreutils to pull its man page onto disk.
13+RUN pacman -Sy --noconfirm archlinux-keyring \
14+ && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \
15+ && pacman -Syu --noconfirm \
16+ vhs ttyd ffmpeg chromium \
17+ btop fastfetch gifsicle \
18+ eza neovim man-db man-pages coreutils \
19+ nodejs npm ncurses \
20+ ttf-jetbrains-mono-nerd \
21+ && pacman -Scc --noconfirm
22+RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code
23+RUN setcap -r /usr/sbin/btop
24+RUN useradd -m -u 1000 demo
25+COPY --from=build /out/vev /usr/local/bin/vev
26+COPY scripts/demo/ticker.sh /usr/local/bin/ticker
27+RUN chmod +x /usr/local/bin/ticker
28+USER demo
29+WORKDIR /home/demo
30+ENV VHS_NO_SANDBOX=true
31+ENV COLORTERM=truecolor
32+ENV EZA_ICONS_AUTO=1
33+ENTRYPOINT ["vhs"]
@@ -0,0 +1,33 @@
1+# syntax=docker/dockerfile:1
2+FROM golang:1.26 AS build
3+WORKDIR /src
4+COPY go.mod go.sum ./
5+RUN go mod download
6+COPY . .
7+RUN CGO_ENABLED=0 go build -o /out/vev .
8+
9+FROM archlinux:latest
10+# The base image ships a pacman.conf that skips extracting man/info pages
11+# (NoExtract) to stay small; the demo wants a real `man ls`, so drop that
12+# rule and reinstall coreutils to pull its man page onto disk.
13+RUN pacman -Sy --noconfirm archlinux-keyring \
14+ && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \
15+ && pacman -Syu --noconfirm \
16+ vhs ttyd ffmpeg chromium \
17+ btop fastfetch gifsicle \
18+ eza neovim man-db man-pages coreutils \
19+ nodejs npm ncurses \
20+ ttf-jetbrains-mono-nerd \
21+ && pacman -Scc --noconfirm
22+RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code
23+RUN setcap -r /usr/sbin/btop
24+RUN useradd -m -u 1000 demo
25+COPY --from=build /out/vev /usr/local/bin/vev
26+COPY scripts/demo/ticker.sh /usr/local/bin/ticker
27+RUN chmod +x /usr/local/bin/ticker
28+USER demo
29+WORKDIR /home/demo
30+ENV VHS_NO_SANDBOX=true
31+ENV COLORTERM=truecolor
32+ENV EZA_ICONS_AUTO=1
33+ENTRYPOINT ["vhs"]
A scripts/demo/claude-settings.json +6 -0
@@ -0,0 +1,6 @@
1+{
2+ "preferredNotifChannel": "terminal_bell",
3+ "permissions": {
4+ "allow": ["Bash(sleep:*)"]
5+ }
6+}
@@ -0,0 +1,6 @@
1+{
2+ "preferredNotifChannel": "terminal_bell",
3+ "permissions": {
4+ "allow": ["Bash(sleep:*)"]
5+ }
6+}
A scripts/demo/demo.tape +177 -0
@@ -0,0 +1,177 @@
1+Require vev
2+Output out/demo.gif
3+Set Shell bash
4+Set FontFamily "JetBrainsMono Nerd Font"
5+Set FontSize 20
6+Set Width 1200
7+Set Height 750
8+Set Padding 0
9+Set Theme "GitHub Dark"
10+Set TypingSpeed 100ms
11+Set Framerate 30
12+Set WaitTimeout 30s
13+
14+# Visible shell commands are typed with hand-varied Type@<speed>
15+# (70-160ms) and short hesitations at word boundaries so the rhythm
16+# reads irregular/human; palette codes (CNS/SSP/…) stay single-speed.
17+
18+# ── Session "work": a colourful split (eza + ticker) ─────
19+Type@90ms "vev "
20+Sleep 160ms
21+Type@75ms "new "
22+Type@140ms "work"
23+Enter
24+Sleep 1.1s
25+Type@85ms "eza "
26+Sleep 200ms
27+Type@75ms "-la "
28+Type@130ms "/etc"
29+Enter
30+Sleep 1.5s
31+Alt+" "
32+Sleep 550ms
33+Type "SPR"
34+Enter
35+Sleep 650ms
36+Type@80ms "tick"
37+Type@150ms "er"
38+Enter
39+Sleep 1.5s
40+
41+# ── Session "agents": launch the bell job early so its
42+# sleep overlaps the middle scenes. claude -p prints the
43+# answer but emits no bell of its own (verified), so the
44+# pane rings it with printf. ───────────────────────────
45+Alt+" "
46+Sleep 550ms
47+Type "CNS"
48+Enter
49+Sleep 550ms
50+Type@95ms "agents"
51+Enter
52+Sleep 900ms
53+Type@80ms "sleep "
54+Type@140ms "10 "
55+Sleep 170ms
56+Type@90ms "&& claude "
57+Type@150ms "-p "
58+Type@105ms `"say hello world" `
59+Sleep 220ms
60+Type@85ms "&& printf "
61+Type@130ms `'\a'`
62+Enter
63+Sleep 1s
64+
65+# Back to work — palette-typed session switch: type the session
66+# name directly (no command code), it ranks top of the results.
67+Alt+" "
68+Sleep 550ms
69+Type@95ms "work"
70+Enter
71+Sleep 1s
72+
73+# ── Session "work": stacked panes (ticker keeps ticking) ─
74+# Member 1 is the eza pane already on screen; member 2 opens the
75+# ticker script itself in neovim (self-referential, bash syntax).
76+Alt+h
77+Sleep 550ms
78+Alt+" "
79+Sleep 550ms
80+Type "STP"
81+Enter
82+Sleep 650ms
83+Type@85ms "nvim "
84+Sleep 150ms
85+Type@110ms "/usr/local/bin/"
86+Type@130ms "ticker"
87+Enter
88+Sleep 1.5s
89+Alt+k
90+Sleep 900ms
91+Alt+j
92+Sleep 900ms
93+
94+# ── Session "work": floating pane preserves its program ──
95+# Open a man page, scroll it, hide, then show again at the same
96+# scroll position — proves the float keeps program + scroll state.
97+Alt+f
98+Sleep 550ms
99+Type@90ms "man "
100+Type@130ms "ls"
101+Enter
102+Sleep 1.2s
103+Space
104+Sleep 700ms
105+Alt+f
106+Sleep 900ms
107+Alt+f
108+Sleep 1.2s
109+Type "q"
110+Sleep 550ms
111+Alt+f
112+Sleep 500ms
113+
114+# ── Session "monitor": fastfetch + btop, each full-width,
115+# never split. ─────────────────────────────────────────
116+Alt+" "
117+Sleep 550ms
118+Type "CNS"
119+Enter
120+Sleep 550ms
121+Type@95ms "monitor"
122+Enter
123+Sleep 900ms
124+Type@80ms "fast"
125+Type@130ms "fetch"
126+Enter
127+Sleep 1.7s
128+Alt+" "
129+Sleep 550ms
130+Type "CNT"
131+Enter
132+Sleep 550ms
133+Type@90ms "btop"
134+Enter
135+Sleep 1.9s
136+Alt+"1"
137+Sleep 1.2s
138+Alt+"2"
139+Sleep 1.1s
140+
141+# ── Bell cross-session: the agents job has rung; jump to it ─
142+Sleep 900ms
143+Alt+a
144+Sleep 1.9s
145+
146+# ── Session picker now lists all three sessions ──────────
147+Alt+" "
148+Sleep 550ms
149+Type "SSP"
150+Enter
151+Sleep 1.2s
152+Down
153+Sleep 450ms
154+Down
155+Sleep 450ms
156+Down
157+Sleep 650ms
158+Enter
159+Sleep 1.2s
160+
161+# ── Detach / re-attach: three sessions survive, ticker on ─
162+Alt+" "
163+Sleep 550ms
164+Type "DET"
165+Enter
166+Sleep 1s
167+Type@90ms "vev "
168+Type@75ms "ls"
169+Enter
170+Sleep 1.8s
171+Type@90ms "vev "
172+Sleep 150ms
173+Type@80ms "attach "
174+Type@140ms "work"
175+Enter
176+Sleep 2.2s
177+Sleep 400ms
@@ -0,0 +1,177 @@
1+Require vev
2+Output out/demo.gif
3+Set Shell bash
4+Set FontFamily "JetBrainsMono Nerd Font"
5+Set FontSize 20
6+Set Width 1200
7+Set Height 750
8+Set Padding 0
9+Set Theme "GitHub Dark"
10+Set TypingSpeed 100ms
11+Set Framerate 30
12+Set WaitTimeout 30s
13+
14+# Visible shell commands are typed with hand-varied Type@<speed>
15+# (70-160ms) and short hesitations at word boundaries so the rhythm
16+# reads irregular/human; palette codes (CNS/SSP/…) stay single-speed.
17+
18+# ── Session "work": a colourful split (eza + ticker) ─────
19+Type@90ms "vev "
20+Sleep 160ms
21+Type@75ms "new "
22+Type@140ms "work"
23+Enter
24+Sleep 1.1s
25+Type@85ms "eza "
26+Sleep 200ms
27+Type@75ms "-la "
28+Type@130ms "/etc"
29+Enter
30+Sleep 1.5s
31+Alt+" "
32+Sleep 550ms
33+Type "SPR"
34+Enter
35+Sleep 650ms
36+Type@80ms "tick"
37+Type@150ms "er"
38+Enter
39+Sleep 1.5s
40+
41+# ── Session "agents": launch the bell job early so its
42+# sleep overlaps the middle scenes. claude -p prints the
43+# answer but emits no bell of its own (verified), so the
44+# pane rings it with printf. ───────────────────────────
45+Alt+" "
46+Sleep 550ms
47+Type "CNS"
48+Enter
49+Sleep 550ms
50+Type@95ms "agents"
51+Enter
52+Sleep 900ms
53+Type@80ms "sleep "
54+Type@140ms "10 "
55+Sleep 170ms
56+Type@90ms "&& claude "
57+Type@150ms "-p "
58+Type@105ms `"say hello world" `
59+Sleep 220ms
60+Type@85ms "&& printf "
61+Type@130ms `'\a'`
62+Enter
63+Sleep 1s
64+
65+# Back to work — palette-typed session switch: type the session
66+# name directly (no command code), it ranks top of the results.
67+Alt+" "
68+Sleep 550ms
69+Type@95ms "work"
70+Enter
71+Sleep 1s
72+
73+# ── Session "work": stacked panes (ticker keeps ticking) ─
74+# Member 1 is the eza pane already on screen; member 2 opens the
75+# ticker script itself in neovim (self-referential, bash syntax).
76+Alt+h
77+Sleep 550ms
78+Alt+" "
79+Sleep 550ms
80+Type "STP"
81+Enter
82+Sleep 650ms
83+Type@85ms "nvim "
84+Sleep 150ms
85+Type@110ms "/usr/local/bin/"
86+Type@130ms "ticker"
87+Enter
88+Sleep 1.5s
89+Alt+k
90+Sleep 900ms
91+Alt+j
92+Sleep 900ms
93+
94+# ── Session "work": floating pane preserves its program ──
95+# Open a man page, scroll it, hide, then show again at the same
96+# scroll position — proves the float keeps program + scroll state.
97+Alt+f
98+Sleep 550ms
99+Type@90ms "man "
100+Type@130ms "ls"
101+Enter
102+Sleep 1.2s
103+Space
104+Sleep 700ms
105+Alt+f
106+Sleep 900ms
107+Alt+f
108+Sleep 1.2s
109+Type "q"
110+Sleep 550ms
111+Alt+f
112+Sleep 500ms
113+
114+# ── Session "monitor": fastfetch + btop, each full-width,
115+# never split. ─────────────────────────────────────────
116+Alt+" "
117+Sleep 550ms
118+Type "CNS"
119+Enter
120+Sleep 550ms
121+Type@95ms "monitor"
122+Enter
123+Sleep 900ms
124+Type@80ms "fast"
125+Type@130ms "fetch"
126+Enter
127+Sleep 1.7s
128+Alt+" "
129+Sleep 550ms
130+Type "CNT"
131+Enter
132+Sleep 550ms
133+Type@90ms "btop"
134+Enter
135+Sleep 1.9s
136+Alt+"1"
137+Sleep 1.2s
138+Alt+"2"
139+Sleep 1.1s
140+
141+# ── Bell cross-session: the agents job has rung; jump to it ─
142+Sleep 900ms
143+Alt+a
144+Sleep 1.9s
145+
146+# ── Session picker now lists all three sessions ──────────
147+Alt+" "
148+Sleep 550ms
149+Type "SSP"
150+Enter
151+Sleep 1.2s
152+Down
153+Sleep 450ms
154+Down
155+Sleep 450ms
156+Down
157+Sleep 650ms
158+Enter
159+Sleep 1.2s
160+
161+# ── Detach / re-attach: three sessions survive, ticker on ─
162+Alt+" "
163+Sleep 550ms
164+Type "DET"
165+Enter
166+Sleep 1s
167+Type@90ms "vev "
168+Type@75ms "ls"
169+Enter
170+Sleep 1.8s
171+Type@90ms "vev "
172+Sleep 150ms
173+Type@80ms "attach "
174+Type@140ms "work"
175+Enter
176+Sleep 2.2s
177+Sleep 400ms
A scripts/demo/run.sh +52 -0
@@ -0,0 +1,52 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+cd "$(dirname "$0")/../.."
4+
5+TAPE="${1:-demo.tape}"
6+install -d -m 0777 scripts/demo/out
7+
8+CLAUDE_TMP=$(mktemp -d)
9+# claude writes files into the temp HOME as the container's demo user, which
10+# rootless docker maps to a subuid the host cannot delete directly. Clean up
11+# through the container first, where the creating uid can unlink its own
12+# files, then remove the (now-empty) temp dir from the host. This never
13+# suppresses a leftover: if anything still remains, it's reported so a
14+# credentials-bearing directory doesn't silently linger in /tmp.
15+cleanup() {
16+ # Every step here is best-effort: under `set -e`, an unguarded failure in an
17+ # EXIT trap would abort the trap early and replace the script's real exit
18+ # status with the failing cleanup command's. Guard each step with `|| true`
19+ # so cleanup always runs to completion and never masks the vhs run's result;
20+ # the final check below is what surfaces an unrecovered leftover.
21+ rm -rf "$CLAUDE_TMP" 2>/dev/null || true
22+ if [ -d "$CLAUDE_TMP" ]; then
23+ docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \
24+ -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true
25+ rmdir "$CLAUDE_TMP" 2>/dev/null || true
26+ fi
27+ if [ -d "$CLAUDE_TMP" ]; then
28+ echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&2
29+ fi
30+ return 0
31+}
32+trap cleanup EXIT
33+mkdir -p "$CLAUDE_TMP/.claude"
34+cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/"
35+cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json"
36+printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json"
37+
38+# Rootless docker maps host-owned files (uid 1000) to container root, but the
39+# container runs as demo (uid 1000 -> a subuid), so demo cannot read the
40+# credentials or write its config dir. Open up the throwaway home's contents so
41+# claude can authenticate and write transcripts, then restore the mktemp
42+# parent to 0700: bind mounts hand the container the inner paths directly, but
43+# other host users would have to traverse the parent, which now blocks them.
44+chmod -R a+rwX "$CLAUDE_TMP"
45+chmod 0700 "$CLAUDE_TMP"
46+
47+docker run --rm --hostname vev-demo \
48+ -v "$PWD/scripts/demo:/tape:ro" \
49+ -v "$PWD/scripts/demo/out:/home/demo/out" \
50+ -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \
51+ -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \
52+ vev-demo "/tape/$TAPE"
@@ -0,0 +1,52 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+cd "$(dirname "$0")/../.."
4+
5+TAPE="${1:-demo.tape}"
6+install -d -m 0777 scripts/demo/out
7+
8+CLAUDE_TMP=$(mktemp -d)
9+# claude writes files into the temp HOME as the container's demo user, which
10+# rootless docker maps to a subuid the host cannot delete directly. Clean up
11+# through the container first, where the creating uid can unlink its own
12+# files, then remove the (now-empty) temp dir from the host. This never
13+# suppresses a leftover: if anything still remains, it's reported so a
14+# credentials-bearing directory doesn't silently linger in /tmp.
15+cleanup() {
16+ # Every step here is best-effort: under `set -e`, an unguarded failure in an
17+ # EXIT trap would abort the trap early and replace the script's real exit
18+ # status with the failing cleanup command's. Guard each step with `|| true`
19+ # so cleanup always runs to completion and never masks the vhs run's result;
20+ # the final check below is what surfaces an unrecovered leftover.
21+ rm -rf "$CLAUDE_TMP" 2>/dev/null || true
22+ if [ -d "$CLAUDE_TMP" ]; then
23+ docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \
24+ -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true
25+ rmdir "$CLAUDE_TMP" 2>/dev/null || true
26+ fi
27+ if [ -d "$CLAUDE_TMP" ]; then
28+ echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&2
29+ fi
30+ return 0
31+}
32+trap cleanup EXIT
33+mkdir -p "$CLAUDE_TMP/.claude"
34+cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/"
35+cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json"
36+printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json"
37+
38+# Rootless docker maps host-owned files (uid 1000) to container root, but the
39+# container runs as demo (uid 1000 -> a subuid), so demo cannot read the
40+# credentials or write its config dir. Open up the throwaway home's contents so
41+# claude can authenticate and write transcripts, then restore the mktemp
42+# parent to 0700: bind mounts hand the container the inner paths directly, but
43+# other host users would have to traverse the parent, which now blocks them.
44+chmod -R a+rwX "$CLAUDE_TMP"
45+chmod 0700 "$CLAUDE_TMP"
46+
47+docker run --rm --hostname vev-demo \
48+ -v "$PWD/scripts/demo:/tape:ro" \
49+ -v "$PWD/scripts/demo/out:/home/demo/out" \
50+ -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \
51+ -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \
52+ vev-demo "/tape/$TAPE"
A scripts/demo/smoke.tape +30 -0
@@ -0,0 +1,30 @@
1+Require vev
2+Output out/smoke.gif
3+Set Shell bash
4+Set FontSize 20
5+Set Width 1200
6+Set Height 750
7+Set TypingSpeed 50ms
8+
9+Type "vev new smoke"
10+Enter
11+Sleep 2s
12+Screenshot out/01-session.png
13+Alt+" "
14+Sleep 1s
15+Screenshot out/02-palette.png
16+Escape
17+Sleep 500ms
18+Alt+" "
19+Sleep 500ms
20+Type "DET"
21+Enter
22+Sleep 1.5s
23+Type "claude --version"
24+Enter
25+Sleep 3s
26+Type "vev kill smoke"
27+Enter
28+Sleep 1s
29+Screenshot out/03-shell.png
30+Sleep 500ms
@@ -0,0 +1,30 @@
1+Require vev
2+Output out/smoke.gif
3+Set Shell bash
4+Set FontSize 20
5+Set Width 1200
6+Set Height 750
7+Set TypingSpeed 50ms
8+
9+Type "vev new smoke"
10+Enter
11+Sleep 2s
12+Screenshot out/01-session.png
13+Alt+" "
14+Sleep 1s
15+Screenshot out/02-palette.png
16+Escape
17+Sleep 500ms
18+Alt+" "
19+Sleep 500ms
20+Type "DET"
21+Enter
22+Sleep 1.5s
23+Type "claude --version"
24+Enter
25+Sleep 3s
26+Type "vev kill smoke"
27+Enter
28+Sleep 1s
29+Screenshot out/03-shell.png
30+Sleep 500ms
A scripts/demo/ticker.sh +9 -0
@@ -0,0 +1,9 @@
1+#!/usr/bin/env bash
2+# Prints a visibly advancing counter; proves the session kept running
3+# while detached. Narrow-safe: one short line per second.
4+i=0
5+while true; do
6+ printf '\033[38;5;213mtick\033[0m %d\n' "$i"
7+ i=$((i + 1))
8+ sleep 1
9+done
@@ -0,0 +1,9 @@
1+#!/usr/bin/env bash
2+# Prints a visibly advancing counter; proves the session kept running
3+# while detached. Narrow-safe: one short line per second.
4+i=0
5+while true; do
6+ printf '\033[38;5;213mtick\033[0m %d\n' "$i"
7+ i=$((i + 1))
8+ sleep 1
9+done

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close