detached 46d259b

fix(palette): use the compact rail for every floating width (#300)

* fix(palette): use the compact rail for every floating width

* fix(demo): restore make demo and re-record the demo

* docs(demo): slow key beats and show moving a live pane

* docs(demo): show the floating pane once

* docs(demo): move a live fzf pane and pause on STP

* fix(demo): keep demo state in a volume and check session bootstrap
commit
46d259b70ac4c63f6201ba49d5ee0f764bf2b207
author
bnema · · 2026-09-29 16:24 UTC
committer
GitHub · · 2026-09-29 16:24 UTC
parent
7907dde
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +106 -57
M Makefile +1 -0
@@ -56,6 +56,7 @@ protocol-check:
5656 test -z "$$(git status --porcelain -- internal/protocol/wire/*.pb.go)"
5757
5858 demo:
59+ go build -o build/vev .
5960 docker build -f scripts/demo/Dockerfile -t vev-demo .
6061 ./scripts/demo/run.sh demo.tape
6162 # vhs cannot emit a transparent margin, so cut the window out after the
@@ -56,6 +56,7 @@ protocol-check:
56 test -z "$$(git status --porcelain -- internal/protocol/wire/*.pb.go)"56 test -z "$$(git status --porcelain -- internal/protocol/wire/*.pb.go)"
57 57
58 demo:58 demo:
59+ go build -o build/vev .
59 docker build -f scripts/demo/Dockerfile -t vev-demo .60 docker build -f scripts/demo/Dockerfile -t vev-demo .
60 ./scripts/demo/run.sh demo.tape61 ./scripts/demo/run.sh demo.tape
61 # vhs cannot emit a transparent margin, so cut the window out after the62 # vhs cannot emit a transparent margin, so cut the window out after the
M docs/assets/demo.gif +0 -0 binary file: no text diff

binary file: no text diff

M internal/usecase/daemon/palette.go +5 -13
@@ -16,25 +16,17 @@ import (
1616 "github.com/bnema/vev/internal/usecase/ui"
1717 )
1818
19-const (
20- paletteRailBreakpoint = 96
21- paletteRailWidth = 64
22-)
23-
2419 var (
25- paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
20+ // Floating palettes use a compact rail; below ui.ResponsiveDrawerBreakpoint
21+ // the presentation becomes a full-width drawer regardless of this width.
22+ paletteModal = ui.Modal{FixedWidth: 64, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottomRight, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
2623 errCreateDestinationUnavailable = errors.New("that destination is no longer available")
2724 )
2825
29-func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
26+func paletteModalFor(cfg domain.PaletteConfig) ui.Modal {
3027 modal := paletteModal
31- if size.Cols >= paletteRailBreakpoint {
32- modal.FixedWidth = paletteRailWidth
33- }
3428 if cfg.AnchorSet {
3529 modal.Anchor = cfg.Anchor
36- } else if size.Cols >= paletteRailBreakpoint {
37- modal.Anchor = domain.AnchorBottomRight
3830 }
3931 return modal
4032 }
@@ -1173,7 +1165,7 @@ func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction)
11731165
11741166 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
11751167 styleSet := resolveStyles(styles)
1176- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
1168+ modal := paletteModalFor(cfg)
11771169 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
11781170 return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
11791171 })
@@ -16,25 +16,17 @@ import (
16 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
17 )17 )
18 18
19-const (
20- paletteRailBreakpoint = 96
21- paletteRailWidth = 64
22-)
23-
24 var (19 var (
25- paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}20+ // Floating palettes use a compact rail; below ui.ResponsiveDrawerBreakpoint
21+ // the presentation becomes a full-width drawer regardless of this width.
22+ paletteModal = ui.Modal{FixedWidth: 64, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottomRight, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
26 errCreateDestinationUnavailable = errors.New("that destination is no longer available")23 errCreateDestinationUnavailable = errors.New("that destination is no longer available")
27 )24 )
28 25
29-func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {26+func paletteModalFor(cfg domain.PaletteConfig) ui.Modal {
30 modal := paletteModal27 modal := paletteModal
31- if size.Cols >= paletteRailBreakpoint {
32- modal.FixedWidth = paletteRailWidth
33- }
34 if cfg.AnchorSet {28 if cfg.AnchorSet {
35 modal.Anchor = cfg.Anchor29 modal.Anchor = cfg.Anchor
36- } else if size.Cols >= paletteRailBreakpoint {
37- modal.Anchor = domain.AnchorBottomRight
38 }30 }
39 return modal31 return modal
40 }32 }
@@ -1173,7 +1165,7 @@ func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction)
1173 1165
1174 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {1166 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
1175 styleSet := resolveStyles(styles)1167 styleSet := resolveStyles(styles)
1176- modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)1168+ modal := paletteModalFor(cfg)
1177 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {1169 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
1178 return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})1170 return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
1179 })1171 })
M internal/usecase/daemon/palette_test.go +11 -9
@@ -1063,8 +1063,8 @@ func TestPaletteModalGeometry(t *testing.T) {
10631063 auto := domain.PaletteConfig{}
10641064 tests := []testCase{
10651065 {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
1066- {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}},
1067- {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
1066+ {name: "auto 80 column rail", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 15, Y: 28, Width: 64, Height: 11}},
1067+ {name: "auto 95 column rail", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 30, Y: 28, Width: 64, Height: 11}},
10681068 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
10691069 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
10701070 {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
@@ -1075,12 +1075,13 @@ func TestPaletteModalGeometry(t *testing.T) {
10751075 tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
10761076 }
10771077 for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
1078- tests = append(tests, testCase{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})
1078+ wantX := map[domain.Anchor]int{domain.AnchorLeft: 1, domain.AnchorCenter: 15, domain.AnchorRight: 30}[anchor]
1079+ tests = append(tests, testCase{name: "95 column " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: 14, Width: 64, Height: 11}})
10791080 }
10801081
10811082 for _, tt := range tests {
10821083 t.Run(tt.name, func(t *testing.T) {
1083- modal := paletteModalFor(tt.size, tt.cfg)
1084+ modal := paletteModalFor(tt.cfg)
10841085 presentation := modal.Resolve(tt.size)
10851086 require.Equal(t, tt.want, presentation.Bounds)
10861087 if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
@@ -1102,17 +1103,18 @@ func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
11021103 for _, tt := range []struct {
11031104 name string
11041105 width int
1106+ left int
11051107 topLeft rune
11061108 innerLeft rune
11071109 }{
1108- {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
1109- {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
1110+ {name: "79 column drawer", width: 79, left: 0, topLeft: '─', innerLeft: '>'},
1111+ {name: "80 column rail", width: 80, left: 15, topLeft: '┌', innerLeft: '│'},
11101112 } {
11111113 t.Run(tt.name, func(t *testing.T) {
11121114 frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
11131115
1114- require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
1115- require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
1116+ require.Equal(t, tt.topLeft, frame.At(tt.left, 28).Rune)
1117+ require.Equal(t, tt.innerLeft, frame.At(tt.left, 29).Rune)
11161118 })
11171119 }
11181120 }
@@ -1142,7 +1144,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
11421144 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
11431145
11441146 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
1145- inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
1147+ inner := paletteModalFor(domain.PaletteConfig{}).Resolve(size).Inner
11461148
11471149 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
11481150 }
@@ -1063,8 +1063,8 @@ func TestPaletteModalGeometry(t *testing.T) {
1063 auto := domain.PaletteConfig{}1063 auto := domain.PaletteConfig{}
1064 tests := []testCase{1064 tests := []testCase{
1065 {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},1065 {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
1066- {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}},1066+ {name: "auto 80 column rail", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 15, Y: 28, Width: 64, Height: 11}},
1067- {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},1067+ {name: "auto 95 column rail", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 30, Y: 28, Width: 64, Height: 11}},
1068 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},1068 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
1069 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},1069 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
1070 {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},1070 {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
@@ -1075,12 +1075,13 @@ func TestPaletteModalGeometry(t *testing.T) {
1075 tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})1075 tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
1076 }1076 }
1077 for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {1077 for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
1078- tests = append(tests, testCase{name: "narrow " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: 0, Y: 14, Width: 95, Height: 11}})1078+ wantX := map[domain.Anchor]int{domain.AnchorLeft: 1, domain.AnchorCenter: 15, domain.AnchorRight: 30}[anchor]
1079+ tests = append(tests, testCase{name: "95 column " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: 14, Width: 64, Height: 11}})
1079 }1080 }
1080 1081
1081 for _, tt := range tests {1082 for _, tt := range tests {
1082 t.Run(tt.name, func(t *testing.T) {1083 t.Run(tt.name, func(t *testing.T) {
1083- modal := paletteModalFor(tt.size, tt.cfg)1084+ modal := paletteModalFor(tt.cfg)
1084 presentation := modal.Resolve(tt.size)1085 presentation := modal.Resolve(tt.size)
1085 require.Equal(t, tt.want, presentation.Bounds)1086 require.Equal(t, tt.want, presentation.Bounds)
1086 if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {1087 if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
@@ -1102,17 +1103,18 @@ func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
1102 for _, tt := range []struct {1103 for _, tt := range []struct {
1103 name string1104 name string
1104 width int1105 width int
1106+ left int
1105 topLeft rune1107 topLeft rune
1106 innerLeft rune1108 innerLeft rune
1107 }{1109 }{
1108- {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},1110+ {name: "79 column drawer", width: 79, left: 0, topLeft: '─', innerLeft: '>'},
1109- {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},1111+ {name: "80 column rail", width: 80, left: 15, topLeft: '┌', innerLeft: '│'},
1110 } {1112 } {
1111 t.Run(tt.name, func(t *testing.T) {1113 t.Run(tt.name, func(t *testing.T) {
1112 frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")1114 frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
1113 1115
1114- require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)1116+ require.Equal(t, tt.topLeft, frame.At(tt.left, 28).Rune)
1115- require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)1117+ require.Equal(t, tt.innerLeft, frame.At(tt.left, 29).Rune)
1116 })1118 })
1117 }1119 }
1118 }1120 }
@@ -1142,7 +1144,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
1142 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles1144 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
1143 1145
1144 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)1146 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
1145- inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner1147+ inner := paletteModalFor(domain.PaletteConfig{}).Resolve(size).Inner
1146 1148
1147 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))1149 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
1148 }1150 }
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -312,7 +312,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
312312 o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
313313 }
314314 if snap.paletteActive && snap.paletteModel != nil {
315- modal := paletteModalFor(size, paletteCfg)
315+ modal := paletteModalFor(paletteCfg)
316316 presentation := modal.Resolve(size)
317317 o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
318318 guidance := ""
@@ -312,7 +312,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
312 o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)312 o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
313 }313 }
314 if snap.paletteActive && snap.paletteModel != nil {314 if snap.paletteActive && snap.paletteModel != nil {
315- modal := paletteModalFor(size, paletteCfg)315+ modal := paletteModalFor(paletteCfg)
316 presentation := modal.Resolve(size)316 presentation := modal.Resolve(size)
317 o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}317 o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
318 guidance := ""318 guidance := ""
M scripts/demo/Dockerfile +2 -2
@@ -6,7 +6,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
66 && pacman -Syu --noconfirm \
77 vhs ttyd ffmpeg chromium \
88 htop fastfetch gifsicle imagemagick \
9- eza neovim man-db man-pages coreutils \
9+ eza fzf neovim man-db man-pages coreutils \
1010 ncurses openssh util-linux \
1111 ttf-jetbrains-mono-nerd
1212 RUN useradd -m -u 1000 demo
@@ -16,7 +16,7 @@ COPY build/vev /usr/local/bin/vev
1616 COPY scripts/demo/ticker.sh /usr/local/bin/ticker
1717 COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell
1818 RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \
19- && install -d -o demo -g demo -m 0700 /home/demo/.ssh \
19+ && install -d -o demo -g demo -m 0700 /home/demo/.ssh /home/demo/.local \
2020 && install -d -m 0755 /run/sshd \
2121 && ssh-keygen -A \
2222 && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \
@@ -6,7 +6,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
6 && pacman -Syu --noconfirm \6 && pacman -Syu --noconfirm \
7 vhs ttyd ffmpeg chromium \7 vhs ttyd ffmpeg chromium \
8 htop fastfetch gifsicle imagemagick \8 htop fastfetch gifsicle imagemagick \
9- eza neovim man-db man-pages coreutils \9+ eza fzf neovim man-db man-pages coreutils \
10 ncurses openssh util-linux \10 ncurses openssh util-linux \
11 ttf-jetbrains-mono-nerd11 ttf-jetbrains-mono-nerd
12 RUN useradd -m -u 1000 demo12 RUN useradd -m -u 1000 demo
@@ -16,7 +16,7 @@ COPY build/vev /usr/local/bin/vev
16 COPY scripts/demo/ticker.sh /usr/local/bin/ticker16 COPY scripts/demo/ticker.sh /usr/local/bin/ticker
17 COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell17 COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell
18 RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \18 RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \
19- && install -d -o demo -g demo -m 0700 /home/demo/.ssh \19+ && install -d -o demo -g demo -m 0700 /home/demo/.ssh /home/demo/.local \
20 && install -d -m 0755 /run/sshd \20 && install -d -m 0755 /run/sshd \
21 && ssh-keygen -A \21 && ssh-keygen -A \
22 && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \22 && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \
M scripts/demo/README.md +2 -1
@@ -1,6 +1,7 @@
11 # Demo and sandbox scripts
22
3-- `run.sh`, `demo.tape`, `smoke.tape`: VHS demo recording.
3+- `run.sh`, `demo.tape`, `smoke.tape`, `compose.demo.yaml`: VHS demo recording
4+ (`make demo`). One remote container, `remote-a`.
45 - `sandbox-run.sh`: automated acceptance. Client and two remotes all run in
56 containers on an internal network.
67 - `test-remotes.sh`: two containerised remotes for manual testing from your own
@@ -1,6 +1,7 @@
1 # Demo and sandbox scripts1 # Demo and sandbox scripts
2 2
3-- `run.sh`, `demo.tape`, `smoke.tape`: VHS demo recording.3+- `run.sh`, `demo.tape`, `smoke.tape`, `compose.demo.yaml`: VHS demo recording
4+ (`make demo`). One remote container, `remote-a`.
4 - `sandbox-run.sh`: automated acceptance. Client and two remotes all run in5 - `sandbox-run.sh`: automated acceptance. Client and two remotes all run in
5 containers on an internal network.6 containers on an internal network.
6 - `test-remotes.sh`: two containerised remotes for manual testing from your own7 - `test-remotes.sh`: two containerised remotes for manual testing from your own
A scripts/demo/compose.demo.yaml +22 -0
@@ -0,0 +1,22 @@
1+# VHS demo recording. Driven by scripts/demo/run.sh (make demo).
2+name: vev-demo
3+
4+services:
5+ remote-a:
6+ image: vev-demo
7+ hostname: remote-a
8+ user: root
9+ entrypoint: ["/usr/bin/sshd", "-D", "-e"]
10+
11+ client:
12+ image: vev-demo
13+ depends_on:
14+ - remote-a
15+ volumes:
16+ - ../../scripts/demo:/tape:ro
17+ - ../../scripts/demo/out:/home/demo/out
18+ # Shared by the host-registration and recording runs; removed by `down -v`.
19+ - demo-local:/home/demo/.local
20+
21+volumes:
22+ demo-local:
@@ -0,0 +1,22 @@
1+# VHS demo recording. Driven by scripts/demo/run.sh (make demo).
2+name: vev-demo
3+
4+services:
5+ remote-a:
6+ image: vev-demo
7+ hostname: remote-a
8+ user: root
9+ entrypoint: ["/usr/bin/sshd", "-D", "-e"]
10+
11+ client:
12+ image: vev-demo
13+ depends_on:
14+ - remote-a
15+ volumes:
16+ - ../../scripts/demo:/tape:ro
17+ - ../../scripts/demo/out:/home/demo/out
18+ # Shared by the host-registration and recording runs; removed by `down -v`.
19+ - demo-local:/home/demo/.local
20+
21+volumes:
22+ demo-local:
M scripts/demo/demo.tape +45 -19
@@ -22,7 +22,7 @@ Set WaitTimeout 30s
2222
2323 # ── Local and remote history, shown immediately ──────────
2424 Type@180ms "vev"
25-Sleep 600ms
25+Sleep 1.2s
2626 Enter
2727 Sleep 1.1s
2828 # Keep the initial local shell so the final detach/attach cycle is meaningful.
@@ -30,6 +30,7 @@ Alt+" "
3030 Sleep 550ms
3131 Type "RNS "
3232 Type@95ms "local"
33+Sleep 1s
3334 Enter
3435 Sleep 1s
3536
@@ -39,8 +40,10 @@ Sleep 550ms
3940 Type "SSP"
4041 Enter
4142 Sleep 1.5s
42-Down 2
43-Sleep 700ms
43+Down
44+Sleep 800ms
45+Down
46+Sleep 1.5s
4447 Enter
4548 Sleep 2.5s
4649
@@ -67,6 +70,10 @@ Alt+" "
6770 Sleep 550ms
6871 Type "CNS "
6972 Type@95ms "agents"
73+Sleep 400ms
74+# From a remote session CNS offers two destinations; pick the local one.
75+Down 2
76+Sleep 300ms
7077 Enter
7178 Sleep 900ms
7279 Type@80ms "sleep "
@@ -93,6 +100,7 @@ Sleep 550ms
93100 Alt+" "
94101 Sleep 550ms
95102 Type "STP"
103+Sleep 1.5s
96104 Enter
97105 Sleep 650ms
98106 Type@85ms "nvim "
@@ -106,9 +114,8 @@ Sleep 900ms
106114 Alt+j
107115 Sleep 900ms
108116
109-# ── Session "work": floating pane preserves its program ──
110-# Open a man page, scroll it, hide, then show again at the same
111-# scroll position — proves the float keeps program + scroll state.
117+# ── Session "work": floating pane ─────────────────────
118+# Open a man page in the float, scroll it, then hide the float.
112119 Alt+f
113120 Sleep 550ms
114121 Type@90ms "man "
@@ -116,15 +123,9 @@ Type@130ms "ls"
116123 Enter
117124 Sleep 1.2s
118125 Space
119-Sleep 700ms
120-Alt+f
121-Sleep 900ms
122-Alt+f
123126 Sleep 1.2s
124-Type "q"
125-Sleep 550ms
126127 Alt+f
127-Sleep 500ms
128+Sleep 900ms
128129
129130 # ── Session "monitor": fastfetch + htop, each full-width,
130131 # never split. ─────────────────────────────────────────
@@ -132,6 +133,9 @@ Alt+" "
132133 Sleep 550ms
133134 Type "CNS "
134135 Type@95ms "monitor"
136+Sleep 400ms
137+Down 2
138+Sleep 300ms
135139 Enter
136140 Sleep 900ms
137141 Type@80ms "fast"
@@ -156,16 +160,39 @@ Sleep 900ms
156160 Alt+a
157161 Sleep 1.9s
158162
159-# ── Session picker now lists local and remote sessions ───
163+# ── Move pane: start fzf beside the agents job, type a query,
164+# then carry it live into the "local" session. ───────────
160165 Alt+" "
161166 Sleep 550ms
162-Type "SSP"
167+Type "SPR"
168+Enter
169+Sleep 650ms
170+Type@85ms "cd "
171+Type@120ms "/etc "
172+Type@80ms "&& "
173+Type@110ms "fzf"
174+Enter
175+Sleep 1.2s
176+Type@140ms "ssh"
177+Sleep 1.2s
178+Alt+" "
179+Sleep 550ms
180+Type "MFP"
181+Sleep 800ms
182+Enter
183+Sleep 1.5s
184+Down
185+Sleep 800ms
186+Down
187+Sleep 1.5s
163188 Enter
189+# The picker stays open after a pane move so more panes can follow; close it.
190+# The final re-attach to "local" shows fzf still open with its query.
164191 Sleep 1.5s
165192 Escape
166-Sleep 650ms
193+Sleep 1s
167194
168-# ── Detach / re-attach: local sessions survive, ticker on ─
195+# ── Detach / re-attach: local sessions survive, fzf intact ─
169196 Alt+" "
170197 Sleep 550ms
171198 Type "DET"
@@ -181,5 +208,4 @@ Sleep 150ms
181208 Type@80ms "attach "
182209 Type@140ms "local"
183210 Enter
184-Sleep 2.2s
185-Sleep 400ms
211+Sleep 3.5s
@@ -22,7 +22,7 @@ Set WaitTimeout 30s
22 22
23 # ── Local and remote history, shown immediately ──────────23 # ── Local and remote history, shown immediately ──────────
24 Type@180ms "vev"24 Type@180ms "vev"
25-Sleep 600ms25+Sleep 1.2s
26 Enter26 Enter
27 Sleep 1.1s27 Sleep 1.1s
28 # Keep the initial local shell so the final detach/attach cycle is meaningful.28 # Keep the initial local shell so the final detach/attach cycle is meaningful.
@@ -30,6 +30,7 @@ Alt+" "
30 Sleep 550ms30 Sleep 550ms
31 Type "RNS "31 Type "RNS "
32 Type@95ms "local"32 Type@95ms "local"
33+Sleep 1s
33 Enter34 Enter
34 Sleep 1s35 Sleep 1s
35 36
@@ -39,8 +40,10 @@ Sleep 550ms
39 Type "SSP"40 Type "SSP"
40 Enter41 Enter
41 Sleep 1.5s42 Sleep 1.5s
42-Down 243+Down
43-Sleep 700ms44+Sleep 800ms
45+Down
46+Sleep 1.5s
44 Enter47 Enter
45 Sleep 2.5s48 Sleep 2.5s
46 49
@@ -67,6 +70,10 @@ Alt+" "
67 Sleep 550ms70 Sleep 550ms
68 Type "CNS "71 Type "CNS "
69 Type@95ms "agents"72 Type@95ms "agents"
73+Sleep 400ms
74+# From a remote session CNS offers two destinations; pick the local one.
75+Down 2
76+Sleep 300ms
70 Enter77 Enter
71 Sleep 900ms78 Sleep 900ms
72 Type@80ms "sleep "79 Type@80ms "sleep "
@@ -93,6 +100,7 @@ Sleep 550ms
93 Alt+" "100 Alt+" "
94 Sleep 550ms101 Sleep 550ms
95 Type "STP"102 Type "STP"
103+Sleep 1.5s
96 Enter104 Enter
97 Sleep 650ms105 Sleep 650ms
98 Type@85ms "nvim "106 Type@85ms "nvim "
@@ -106,9 +114,8 @@ Sleep 900ms
106 Alt+j114 Alt+j
107 Sleep 900ms115 Sleep 900ms
108 116
109-# ── Session "work": floating pane preserves its program ──117+# ── Session "work": floating pane ─────────────────────
110-# Open a man page, scroll it, hide, then show again at the same118+# Open a man page in the float, scroll it, then hide the float.
111-# scroll position — proves the float keeps program + scroll state.
112 Alt+f119 Alt+f
113 Sleep 550ms120 Sleep 550ms
114 Type@90ms "man "121 Type@90ms "man "
@@ -116,15 +123,9 @@ Type@130ms "ls"
116 Enter123 Enter
117 Sleep 1.2s124 Sleep 1.2s
118 Space125 Space
119-Sleep 700ms
120-Alt+f
121-Sleep 900ms
122-Alt+f
123 Sleep 1.2s126 Sleep 1.2s
124-Type "q"
125-Sleep 550ms
126 Alt+f127 Alt+f
127-Sleep 500ms128+Sleep 900ms
128 129
129 # ── Session "monitor": fastfetch + htop, each full-width,130 # ── Session "monitor": fastfetch + htop, each full-width,
130 # never split. ─────────────────────────────────────────131 # never split. ─────────────────────────────────────────
@@ -132,6 +133,9 @@ Alt+" "
132 Sleep 550ms133 Sleep 550ms
133 Type "CNS "134 Type "CNS "
134 Type@95ms "monitor"135 Type@95ms "monitor"
136+Sleep 400ms
137+Down 2
138+Sleep 300ms
135 Enter139 Enter
136 Sleep 900ms140 Sleep 900ms
137 Type@80ms "fast"141 Type@80ms "fast"
@@ -156,16 +160,39 @@ Sleep 900ms
156 Alt+a160 Alt+a
157 Sleep 1.9s161 Sleep 1.9s
158 162
159-# ── Session picker now lists local and remote sessions ───163+# ── Move pane: start fzf beside the agents job, type a query,
164+# then carry it live into the "local" session. ───────────
160 Alt+" "165 Alt+" "
161 Sleep 550ms166 Sleep 550ms
162-Type "SSP"167+Type "SPR"
168+Enter
169+Sleep 650ms
170+Type@85ms "cd "
171+Type@120ms "/etc "
172+Type@80ms "&& "
173+Type@110ms "fzf"
174+Enter
175+Sleep 1.2s
176+Type@140ms "ssh"
177+Sleep 1.2s
178+Alt+" "
179+Sleep 550ms
180+Type "MFP"
181+Sleep 800ms
182+Enter
183+Sleep 1.5s
184+Down
185+Sleep 800ms
186+Down
187+Sleep 1.5s
163 Enter188 Enter
189+# The picker stays open after a pane move so more panes can follow; close it.
190+# The final re-attach to "local" shows fzf still open with its query.
164 Sleep 1.5s191 Sleep 1.5s
165 Escape192 Escape
166-Sleep 650ms193+Sleep 1s
167 194
168-# ── Detach / re-attach: local sessions survive, ticker on ─195+# ── Detach / re-attach: local sessions survive, fzf intact ─
169 Alt+" "196 Alt+" "
170 Sleep 550ms197 Sleep 550ms
171 Type "DET"198 Type "DET"
@@ -181,5 +208,4 @@ Sleep 150ms
181 Type@80ms "attach "208 Type@80ms "attach "
182 Type@140ms "local"209 Type@140ms "local"
183 Enter210 Enter
184-Sleep 2.2s211+Sleep 3.5s
185-Sleep 400ms
M scripts/demo/run.sh +17 -12
@@ -3,11 +3,8 @@ set -euo pipefail
33 cd "$(dirname "$0")/../.."
44
55 TAPE="${1:-demo.tape}"
6-COMPOSE_FILE="scripts/demo/compose.yaml"
6+COMPOSE_FILE="scripts/demo/compose.demo.yaml"
77 install -d -m 0777 scripts/demo/out
8-# Demo containers use a rootless subuid mapping, so reset generated state
9-# from the same container identity rather than relying on host ownership.
10-docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local'
118
129 compose() {
1310 docker compose -f "$COMPOSE_FILE" "$@"
@@ -18,11 +15,13 @@ cleanup() {
1815 }
1916 trap cleanup EXIT
2017
21-compose up -d remote
18+# Start from a fresh named volume for the client's vev state.
19+cleanup
20+compose up -d remote-a
2221
2322 ready=""
2423 for _ in $(seq 60); do
25- if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then
24+ if compose run --rm --entrypoint ssh client remote-a true >/dev/null 2>&1; then
2625 ready=yes
2726 break
2827 fi
@@ -30,19 +29,25 @@ for _ in $(seq 60); do
3029 done
3130 if [ -z "$ready" ]; then
3231 echo "error: sshd on the remote container never accepted a connection" >&2
33- compose logs remote >&2 || true
32+ compose logs remote-a >&2 || true
3433 exit 1
3534 fi
3635
3736 start_remote_session() {
3837 local name=$1 scene=$2
39- if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then
40- echo "error: could not create remote session '$name'" >&2
38+ # `vev new` needs a sized terminal. Attach briefly inside a pseudo-terminal;
39+ # the named session survives when the timeout ends the client.
40+ # timeout exits 124 when it ends the attached client, the expected path.
41+ local status=0
42+ compose exec -u demo -T remote-a env TERM=xterm-256color VEV_DEMO_SCENE="$scene" SHELL=/bin/bash \
43+ timeout 3 script -qec "stty cols 120 rows 40; SHELL=/usr/local/bin/demo-shell vev new $name" /dev/null >/dev/null 2>&1 || status=$?
44+ if [ "$status" -ne 124 ]; then
45+ echo "error: 'vev new $name' exited with status $status" >&2
4146 return 1
4247 fi
43- if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then
48+ if ! compose exec -u demo -T remote-a vev ls | awk -v name="$name" '$1 == name && $2 == "up" { found = 1 } END { exit !found }'; then
4449 echo "error: remote session '$name' was not retained" >&2
45- compose exec -u demo -T remote vev ls >&2 || true
50+ compose exec -u demo -T remote-a vev ls >&2 || true
4651 return 1
4752 fi
4853 }
@@ -50,5 +55,5 @@ start_remote_session() {
5055 start_remote_session work remote-deploys
5156 start_remote_session ticker remote-ticker
5257
53-compose run --rm --entrypoint vev client host add remote
58+compose run --rm --entrypoint vev client host add remote-a
5459 compose run --rm client "/tape/$TAPE"
@@ -3,11 +3,8 @@ set -euo pipefail
3 cd "$(dirname "$0")/../.."3 cd "$(dirname "$0")/../.."
4 4
5 TAPE="${1:-demo.tape}"5 TAPE="${1:-demo.tape}"
6-COMPOSE_FILE="scripts/demo/compose.yaml"6+COMPOSE_FILE="scripts/demo/compose.demo.yaml"
7 install -d -m 0777 scripts/demo/out7 install -d -m 0777 scripts/demo/out
8-# Demo containers use a rootless subuid mapping, so reset generated state
9-# from the same container identity rather than relying on host ownership.
10-docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local'
11 8
12 compose() {9 compose() {
13 docker compose -f "$COMPOSE_FILE" "$@"10 docker compose -f "$COMPOSE_FILE" "$@"
@@ -18,11 +15,13 @@ cleanup() {
18 }15 }
19 trap cleanup EXIT16 trap cleanup EXIT
20 17
21-compose up -d remote18+# Start from a fresh named volume for the client's vev state.
19+cleanup
20+compose up -d remote-a
22 21
23 ready=""22 ready=""
24 for _ in $(seq 60); do23 for _ in $(seq 60); do
25- if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then24+ if compose run --rm --entrypoint ssh client remote-a true >/dev/null 2>&1; then
26 ready=yes25 ready=yes
27 break26 break
28 fi27 fi
@@ -30,19 +29,25 @@ for _ in $(seq 60); do
30 done29 done
31 if [ -z "$ready" ]; then30 if [ -z "$ready" ]; then
32 echo "error: sshd on the remote container never accepted a connection" >&231 echo "error: sshd on the remote container never accepted a connection" >&2
33- compose logs remote >&2 || true32+ compose logs remote-a >&2 || true
34 exit 133 exit 1
35 fi34 fi
36 35
37 start_remote_session() {36 start_remote_session() {
38 local name=$1 scene=$237 local name=$1 scene=$2
39- if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then38+ # `vev new` needs a sized terminal. Attach briefly inside a pseudo-terminal;
40- echo "error: could not create remote session '$name'" >&239+ # the named session survives when the timeout ends the client.
40+ # timeout exits 124 when it ends the attached client, the expected path.
41+ local status=0
42+ compose exec -u demo -T remote-a env TERM=xterm-256color VEV_DEMO_SCENE="$scene" SHELL=/bin/bash \
43+ timeout 3 script -qec "stty cols 120 rows 40; SHELL=/usr/local/bin/demo-shell vev new $name" /dev/null >/dev/null 2>&1 || status=$?
44+ if [ "$status" -ne 124 ]; then
45+ echo "error: 'vev new $name' exited with status $status" >&2
41 return 146 return 1
42 fi47 fi
43- if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then48+ if ! compose exec -u demo -T remote-a vev ls | awk -v name="$name" '$1 == name && $2 == "up" { found = 1 } END { exit !found }'; then
44 echo "error: remote session '$name' was not retained" >&249 echo "error: remote session '$name' was not retained" >&2
45- compose exec -u demo -T remote vev ls >&2 || true50+ compose exec -u demo -T remote-a vev ls >&2 || true
46 return 151 return 1
47 fi52 fi
48 }53 }
@@ -50,5 +55,5 @@ start_remote_session() {
50 start_remote_session work remote-deploys55 start_remote_session work remote-deploys
51 start_remote_session ticker remote-ticker56 start_remote_session ticker remote-ticker
52 57
53-compose run --rm --entrypoint vev client host add remote58+compose run --rm --entrypoint vev client host add remote-a
54 compose run --rm client "/tape/$TAPE"59 compose run --rm client "/tape/$TAPE"

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 12

Tags 11

↑↓ moveenter openesc close